diff --git a/.prettierrc.json b/.prettierrc.json index b463fa0a306db0954fd055675d0b8a1622afe5dd..e22dd1dd9bacb0ab12d1d9ceacc6d0525d188333 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -3,7 +3,8 @@ { "files": [ "static/reader*.{js,css,html}", - "scripts/check_reader_release.mjs" + "static/reader/**/*.mjs", + "scripts/build/check_reader_release.mjs" ], "options": { "printWidth": 100, "trailingComma": "none" } } diff --git a/Dockerfile b/Dockerfile index d1070f4414ce117fef50f4fe25be75591d1f4899..a08c46c71a2fb2a87d30fe2e6cfb19a3749ed0e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,10 +11,10 @@ RUN npm install --global "esbuild@${ESBUILD_VERSION}" \ && test "$(esbuild --version)" = "${ESBUILD_VERSION}" COPY static/ /build/static/ -COPY scripts/build_static_assets.mjs /build/build_static_assets.mjs -COPY scripts/copy_reader_vendor.mjs /build/copy_reader_vendor.mjs -COPY scripts/check_reader_release.mjs /build/check_reader_release.mjs -COPY scripts/compose_app.mjs /build/compose_app.mjs +COPY scripts/build/build_static_assets.mjs /build/build_static_assets.mjs +COPY scripts/build/copy_reader_vendor.mjs /build/copy_reader_vendor.mjs +COPY scripts/build/check_reader_release.mjs /build/check_reader_release.mjs +COPY scripts/build/compose_app.mjs /build/compose_app.mjs RUN mkdir -p /out \ && cp -a static/. /out/ \ @@ -22,7 +22,8 @@ RUN mkdir -p /out \ && node /build/compose_app.mjs > /build/app-composed.js \ && esbuild /build/app-composed.js --minify --target=es2020 --charset=utf8 --legal-comments=none --outfile=/out/app.js \ && for file in $(node -e 'console.log(require("./static/reader-resources.js").buildFiles("hf").filter(f => f !== "app.js").join(" "))'); do \ - esbuild "static/$file" --minify --target=es2020 --charset=utf8 --legal-comments=none --outfile="/out/$file" || exit 1; \ + flags=$(node -e 'console.log(require("./static/reader-resources.js").bundleArguments(process.argv[1]).join(" "))' "$file"); \ + esbuild "static/$file" $flags --minify --target=es2020 --charset=utf8 --legal-comments=none --outfile="/out/$file" || exit 1; \ done \ && node /build/build_static_assets.mjs \ && esbuild /out/sw.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/sw.min.js \ @@ -34,9 +35,9 @@ FROM python:3.11.17-slim AS search-index-build WORKDIR /build RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev \ && rm -rf /var/lib/apt/lists/* -COPY search_index_native.c . -COPY scripts/build_search_index_native.py scripts/build_search_index_native.py -RUN python scripts/build_search_index_native.py --output-dir /native +COPY backend/native/search_index_native.c backend/native/search_index_native.c +COPY scripts/build/build_search_index_native.py scripts/build/build_search_index_native.py +RUN python scripts/build/build_search_index_native.py --output-dir /native FROM python:3.11.17-slim @@ -49,7 +50,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY app.py data_formats.py proxy_streams.py record_metadata.py upstream_http.py search_execution.py search_indexes.py reader_assets.py reader_v3_catalog.py . +COPY app.py . +COPY backend/ backend/ COPY --from=search-index-build /native/ /app/ COPY --from=static-assets /out/ static/ COPY data/ data/ diff --git a/app.py b/app.py index 0edb966dde4c902dd033379c399b76a450a8ee97..0d2859a09279a1578695258df2fd83f8c3439a99 100644 --- a/app.py +++ b/app.py @@ -1,2925 +1,3 @@ -import asyncio -import gzip -import hashlib -import json -import posixpath -import random -import re -import sys -import time -import functools -import threading -import weakref -from collections import OrderedDict -from array import array -from uuid import uuid4 -import io -import zipfile -import zlib -from pathlib import Path -from typing import Annotated, Literal, Optional, Sequence -from urllib.parse import unquote, quote, urlparse, parse_qs -import aiohttp -from huggingface_hub import HfFileSystem -import jieba -from fastapi import FastAPI, HTTPException, Query, Request -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, Response -from fastapi.staticfiles import StaticFiles -from fastapi.middleware.cors import CORSMiddleware -from fastapi.middleware.gzip import GZipMiddleware -from pydantic import BaseModel, Field -from contextlib import asynccontextmanager -from proxy_streams import OwnedStreamingResponse, SharedRangeFlight, UpstreamLease -from search_execution import SearchBusy, SearchExecutor -from search_indexes import ( - NGRAM_MAGIC, WORD_INDEX_MAGIC, WORD_INDEX_TOKENIZER, - decode_postings, decode_varint, load_ngram_index, load_word_index, - resolve_postings, -) -from reader_assets import ( - READER_ASSET_FILENAMES, READER_BUCKET_PATH_RE, decode_reader_assets, -) -from reader_v3_catalog import load_reading_catalog, merge_sidecar as merge_v3_sidecar -from data_formats import ( - decode_browser_entry, decode_folder_browser_data, decode_folder_tree_data, - decode_search_payload, decode_tree_node, -) -from record_metadata import ( - READER_ASSET_MODES, - add_reader_assets, build_browser_file_name, build_reader_asset_key, - build_record_link, build_record_path_url, build_record_relative_path, - build_txt_relative_path, enrich_browser_entry, enrich_reader_record, - reader_asset_url, reader_extension_for_asset, reader_resolved_record, -) -from upstream_http import ( - ALLOWED_DOWNLOAD_HOSTS, ALLOWED_DOWNLOAD_HOST_SUFFIXES, MAX_REDIRECTS, - READER_ASSET_PATH_RE, READER_ASSET_SOURCE_RE, VOICEOFML_READER_SOURCE_RE, - decode_url_path, is_allowed_download_host, is_immutable_reader_asset, - normalize_download_url, open_download_response, source_url_scope, - validate_download_url, validate_path_safety, validate_reader_redirect_url, - validate_reader_source_url, validate_voiceofml_redirect_url, validate_voiceofml_source_url, -) -MAX_ARCHIVE_NAME_LENGTH = 240 -DATA_PATH = Path("data/search_data.json.gz") -NGRAM_PATHS = {2: Path("data/search_ngrams_2.bin.gz"), 3: Path("data/search_ngrams_3.bin.gz")} -WORD_INDEX_PATH = Path("data/search_words.bin.gz") -FOLDER_TREE_PATH = Path("data/folder_tree.json.gz") -FOLDER_BROWSER_PATH = Path("data/folder_browser.json.gz") -TXT_DIR = Path("txt") -TXT_SPACE_RAW_BASE = "https://huggingface.co/spaces/VoiceOfML/Search/raw/main/txt" -TXT_WARM_INTERVAL_SECONDS = 600 -TXT_WARM_IDLE_SECONDS = 600 -READER_ASSETS_URL = "https://huggingface.co/buckets/vomebook/reader-assets-v2/resolve/reader-index/reader_assets.json.gz" -READER_ASSETS_CACHE_TTL_SECONDS = 300 -READER_ASSETS_MAX_COMPRESSED_BYTES = 5 * 1024 * 1024 -READER_ASSETS_MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024 -READER_ASSET_ROUTING_CONTRACT_VERSION = "reader-buckets-v2" -records: list[dict] = [] -folder_tree_data: dict[str, list[dict]] = {} -folder_browser_data: dict[str, dict[str, dict]] = {} -word_index = {} -folder_index = {} -extension_counts = {} -repo_counts = {} -repo_list = [] -extension_list = [] -repo_extension_counts = {} -vocab_by_len = {} -repo_records_map = {} -sorted_by_name = [] -sorted_by_size = [] -repo_sorted_by_name = {} -repo_sorted_by_size = {} -txt_record_indices = [] -repo_txt_record_indices = {} -READER_EXTENSIONS = frozenset({"pdf", "djvu", "caj", "kdh", "epub", "mobi", "azw", "azw3", "fb2", "fbz", "html", "htm", "txt", "md", "markdown", "vcf", "ini", "jpg", "jpeg", "png", "gif", "bmp", "tif", "tiff", "webp", "mp3", "mp4", "wav", "m4a", "flac", "mov", "mpga", "wma", "ape", "amr"}) -reader_record_indices = [] -repo_reader_record_indices = {} -reader_record_key_indices = {} -reader_source_id_indices = {} -_reader_indexes_ready = False -_reader_indexes_lock = threading.Lock() -_reader_index_build = None -READER_INDEX_BATCH_RECORDS = 256 -READER_INDEX_BATCH_SECONDS = .008 -READER_INDEX_WARM_DELAY_SECONDS = 5 -READER_INDEX_IDLE_SECONDS = 5 -initial_payload_global = None -initial_payload_by_repo = {} -API_CACHE_TTL_SECONDS = 120 -RESPONSE_CACHE_BYTES_MAX = 16 * 1024 * 1024 -RESPONSE_CACHE_ENTRY_BYTES_MAX = 2 * 1024 * 1024 -_api_response_cache_lock = threading.Lock() -api_response_cache = {} -_data_generation = 0 -SEARCH_CACHE_TTL_SECONDS = 120 -_search_cache: dict[tuple, tuple[float, dict, int]] = {} -_search_instance = uuid4().hex -# Bump when matching, filtering, ordering or public record semantics change. -SEARCH_SEMANTICS_VERSION = "hf-search-v2" -_search_generation = None -_search_logged_generation = None -_search_order_cache = OrderedDict() -SEARCH_ORDER_CACHE_MAX = 32 -SEARCH_ORDER_CACHE_BYTES_MAX = 16 * 1024 * 1024 -SEARCH_ORDER_CACHE_TTL_SECONDS = 600 -_search_anchor_cache = OrderedDict() -SEARCH_ANCHOR_ORDER_MAX = 8 -SEARCH_ANCHOR_PER_ORDER_MAX = 8 -SEARCH_ANCHOR_KEY_MAX = 4096 -METADATA_CACHE_HEADERS = { - "Cache-Control": "public, max-age=300, stale-while-revalidate=1800", -} -ngram_postings: dict[int, dict[str, bytes]] = {} -ngram_posting_buffers: dict[int, bytes] = {} -injected_html_cache: dict[str | None, str] = {} -last_user_request_at = time.monotonic() -reader_assets_cache = {"v": 1, "f": {}} -reader_assets_cache_at = 0.0 -reader_assets_revision = hashlib.sha256(b'{"v":1,"f":{}}').hexdigest() -reader_join_cache_key = None -reader_join_cache = {"all": {}, "repos": {}} +"""Uvicorn entry point for the search service.""" -def _cached(store, key, builder, ttl, limit): - now = time.monotonic() - cached = store.get(key) - if cached and now - cached[0] < ttl: - store[key] = store.pop(key) - return cached[1] - value = builder() - store.pop(key, None) - for old_key, entry in list(store.items()): - if now - entry[0] >= ttl: - del store[old_key] - size = (len(value) if isinstance(value, bytes) else len(json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))) + len(str(key).encode("utf-8")) - if size > RESPONSE_CACHE_ENTRY_BYTES_MAX: - return value - total = sum(entry[2] if len(entry) > 2 else len(json.dumps(entry[1]).encode("utf-8")) for entry in store.values()) - while store and (len(store) >= limit or total + size > RESPONSE_CACHE_BYTES_MAX): - oldest_key = next(iter(store)) - entry = store.pop(oldest_key) - total -= entry[2] if len(entry) > 2 else len(json.dumps(entry[1]).encode("utf-8")) - if size <= RESPONSE_CACHE_BYTES_MAX: - store[key] = (now, value, size) - return value - - -def cached_payload(key, builder): - with _api_response_cache_lock: - return _cached(api_response_cache, key, builder, API_CACHE_TTL_SECONDS, 500) - - -def cached_json_response(key, builder, headers=None): - # Encode once, measure the actual body and reuse it within the existing budget. - body = cached_payload(("json", key), lambda: JSONResponse(builder()).body) - return Response(body, media_type="application/json", headers=headers) - - -async def get_reader_assets(*, wait: bool = True) -> dict: - if reader_assets_cache_at != 0 and time.monotonic() - reader_assets_cache_at < READER_ASSETS_CACHE_TTL_SECONDS: - return reader_assets_cache - task = getattr(app.state, "reader_assets_task", None) - if task is None or task.done(): - task = asyncio.create_task(refresh_reader_assets()) - app.state.reader_assets_task = task - # Search can enrich with the last snapshot while an owned refresh runs. - return await asyncio.shield(task) if wait else reader_assets_cache - - -async def refresh_reader_assets() -> dict: - global reader_assets_cache, reader_assets_cache_at, reader_assets_revision - now = time.monotonic() - if reader_assets_cache_at != 0 and now - reader_assets_cache_at < READER_ASSETS_CACHE_TTL_SECONDS: - return reader_assets_cache - async with app.state.reader_assets_lock: - now = time.monotonic() - if reader_assets_cache_at != 0 and now - reader_assets_cache_at < READER_ASSETS_CACHE_TTL_SECONDS: - return reader_assets_cache - response = None - try: - for url in (READER_ASSETS_URL,): - response = await app.state.http_session.get( - f"{url}?v={int(time.time())}", - timeout=aiohttp.ClientTimeout(total=20, connect=8, sock_read=10), - ) - if response.status == 200: - break - if response.status != 404: - raise RuntimeError(f"reader assets HTTP {response.status}") - response.release() - response = None - if response is None: - raise RuntimeError("reader assets bucket and legacy sources unavailable") - chunks = bytearray() - async for chunk in response.content.iter_chunked(64 * 1024): - chunks.extend(chunk) - if len(chunks) > READER_ASSETS_MAX_COMPRESSED_BYTES: - raise ValueError("reader assets response is too large") - raw = bytes(chunks) - decoded = await asyncio.to_thread(decode_reader_assets, raw) - v3_catalog = await load_reading_catalog(app.state.http_session) - if v3_catalog is None and reader_assets_cache.get("v3"): - raise ValueError("previously published v3 pointer disappeared") - decoded = merge_v3_sidecar(decoded, v3_catalog) - reader_assets_cache = decoded - revision_body = raw + json.dumps(decoded["v3"], sort_keys=True).encode() if v3_catalog else raw - reader_assets_revision = hashlib.sha256(revision_body).hexdigest() - reader_assets_cache_at = time.monotonic() - except Exception as exc: - print(f"reader assets refresh failed: {exc}") - # Retain last-known assets, but retry failures after a short backoff. - reader_assets_cache_at = time.monotonic() - READER_ASSETS_CACHE_TTL_SECONDS + 5 - finally: - if response is not None: - response.release() - return reader_assets_cache - -def records_digest() -> bytes: - digest = hashlib.sha256() - for record in records: - for value in ( - record.get("_file_lower", ""), - record.get("_repo_lower", ""), - record.get("_folder_path_lower", ""), - ): - digest.update(value.encode("utf-8")) - digest.update(b"\0") - return digest.digest() - - -def word_postings(token: str) -> set[int]: - value = word_index.get(token) - return set(value) if value is not None else set() - - -def word_posting_count(token: str) -> int: - value = word_index.get(token) - return len(value) if value is not None else 0 - -def literal_candidates(query: str, repos=None) -> set[int] | None: - width = 3 if len(query) >= 3 and ngram_postings.get(3) else 2 - postings = ngram_postings.get(width) - if not postings or len(query) < width: - return None - entries = [] - for gram in dict.fromkeys(query[index:index + width] for index in range(len(query) - width + 1)): - encoded = postings.get(gram) - if encoded is None: - return set() - entries.append(encoded) - # VNG3 entries carry validated counts; materialized test postings have lengths. - entries.sort(key=lambda entry: entry[2] if isinstance(entry, tuple) else len(entry)) - candidates = None - allowed = None - if repos: - allowed = set() - for repo in repos: - allowed.update(repo_records_map.get(repo, [])) - for encoded in entries: - matches = resolve_postings(encoded, ngram_posting_buffers.get(width, b"")) - if candidates is None: - candidates = matches - if allowed is not None: - candidates.intersection_update(allowed) - else: - candidates.intersection_update(matches) - if not candidates: - return set() - return candidates or set() -INITIAL_PAGE_SIZE = 100 -SIDEBAR_DATA_PATH = Path("data/sidebar") -RECORD_KEY_MAP = { - "r": "Repo", - "f": "File", - "e": "Extension", - "d": "Folder", - "s": "Size", - "t": "HasTxt", -} -TREE_KEY_MAP = { - "n": "name", - "c": "count", - "df": "hasDirectFiles", - "ch": "children", -} -BROWSER_ENTRY_KEY_MAP = { - "d": "folders", - "f": "files", -} -BROWSER_FOLDER_ITEM_KEY_MAP = { - "n": "name", - "c": "count", -} -BROWSER_FILE_ITEM_KEY_MAP = { - "n": "name", - "e": "ext", - "t": "hasTxt", - "s": "size", -} - -READER_SHORT_ID_RE = re.compile(r"^[0-9a-z]{13}$") -# Only lowercase escapes, encoded unreserved ASCII and malformed percent signs -# require normalization. Canonical reserved/UTF-8 escapes can pass through. -READER_ESCAPE_NORMALIZATION_RE = re.compile( - r"%(?:[0-9a-fA-F][a-f]|[a-f][0-9a-fA-F]|2[DE]|3[0-9]|4[1-9A-F]|5[0-9AF]|6[1-9A-F]|7[0-9AE])" - r"|%(?![0-9a-fA-F]{2})" -) - - -def canonical_reader_source_url(value: str) -> str: - # Match reader-contract.js byte normalization without decoding path separators - # or treating an encoded percent sign as a second escape sequence. - def path_replace(match): - def token_replace(token_match): - token = token_match.group() - if len(token) == 3: - char = chr(int(token[1:], 16)) - return char if char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~-" else token.upper() - return "%25" - # Quote once, then skip already-canonical reserved and UTF-8 escapes. - path = quote(match[2], safe="/%") - return match[1] + READER_ESCAPE_NORMALIZATION_RE.sub(token_replace, path) - return re.sub(r"^(https://huggingface\.co/datasets/VoiceOfML/)([^?#]*)", path_replace, value) - - -def reader_short_id(value: str) -> str: - digest = 1469598103934665603 - for byte in canonical_reader_source_url(value).encode("utf-8"): - digest = ((digest ^ byte) * 1099511628211) & ((1 << 64) - 1) - return _base36(digest).rjust(13, "0") - - -def _base36(value: int) -> str: - alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" - output = "" - while value: - value, remainder = divmod(value, 36) - output = alphabet[remainder] + output - return output or "0" - - -def sanitize_archive_name(value) -> str: - name = str(value or "file").replace("\\", "/").rsplit("/", 1)[-1] - name = "".join(ch for ch in name if ord(ch) >= 32 and ch != "\x7f").strip() - if not name or name in (".", ".."): - name = "file" - return name[:MAX_ARCHIVE_NAME_LENGTH] - -def tokenize(text: str) -> list[str]: - tokens = [] - text_lower = text.lower() - alpha_tokens = re.findall(r"[a-z0-9]+", text_lower) - tokens.extend(alpha_tokens) - chinese_text = re.sub(r"[a-z0-9\s]+", " ", text_lower) - chinese_text = re.sub(r"[^\u4e00-\u9fff\u3400-\u4dbf\s]+", " ", chinese_text) - chinese_tokens = jieba.lcut(chinese_text) - tokens.extend([t.strip() for t in chinese_tokens if t.strip()]) - chinese_token_set = set(chinese_tokens) - for ch in text_lower: - if "\u4e00" <= ch <= "\u9fff" or "\u3400" <= ch <= "\u4dbf": - if ch not in chinese_token_set: - tokens.append(ch) - return list(set(tokens)) - -def compile_exact_matcher(query: str): - if "*" not in query and "?" not in query: - lower = query.lower() - return lambda text: lower in text.lower() - # Bitset NFA: each bit is a pattern position. Collapse adjacent stars so - # epsilon closure needs one shift. No input can cause regex backtracking. - pattern = re.sub(r"\*+", "*", query) - stars = questions = 0 - literals = {} - for index, char in enumerate(pattern): - bit = 1 << index - if char == "*": - stars |= bit - elif char == "?": - questions |= bit - else: - literals[char] = literals.get(char, 0) | bit - # Single-character regexes retain Python IGNORECASE's Unicode semantics. - literals = [(re.compile(re.escape(char), re.IGNORECASE), bits) - for char, bits in literals.items()] - masks = {} - end = 1 << len(pattern) - start = 1 | ((1 & stars) << 1) - - def matches(text): - # Every literal is mandatory; C-level scans reject unrelated fields early. - if any(regex.search(text) is None for regex, _ in literals): - return False - active = start - if active & end: - return True - for char in text: - mask = masks.get(char) - if mask is None: - mask = sum(bits for regex, bits in literals if regex.fullmatch(char)) - if len(masks) >= 512: - masks.clear() - masks[char] = mask - wildcard = char != "\n" - active = ((active & (mask | (questions if wildcard else 0))) << 1) | ( - active & stars if wildcard else 0) | 1 - active |= (active & stars) << 1 - if active & end: - return True - return False - return matches - - -def matches_exact_query(text: str, query: str) -> bool: - return compile_exact_matcher(query)(text) - -def edit_distance(s1: str, s2: str, max_dist: int = 2) -> int: - if abs(len(s1) - len(s2)) > max_dist: - return 999 - prev = list(range(len(s2) + 1)) - for i, c1 in enumerate(s1): - curr = [i + 1] - for j, c2 in enumerate(s2): - cost = 0 if c1 == c2 else 1 - curr.append(min(prev[j + 1] + 1, curr[j] + 1, prev[j] + cost)) - if min(curr) > max_dist: - return 999 - prev = curr - return prev[-1] - -def could_be_fuzzy(tok: str, word: str, max_dist: int = 2) -> bool: - if abs(len(tok) - len(word)) > max_dist: - return False - if len(tok) < 4: - return True - counts: dict[str, int] = {} - for ch in tok: - counts[ch] = counts.get(ch, 0) + 1 - diff = 0 - for ch in word: - if counts.get(ch, 0) > 0: - counts[ch] -= 1 - else: - diff += 1 - if diff > 2 * max_dist: - return False - for remaining in counts.values(): - diff += remaining - if diff > 2 * max_dist: - return False - return True - - -def is_fuzzy_match(token: str, word: str) -> bool: - return could_be_fuzzy(token, word, 2) and edit_distance(token, word, max_dist=2) <= 2 - - -def matches_file_token(record: dict, token: str) -> bool: - return token in record.get("_file_lower", "") or token in record.get("_repo_lower", "") - - -def scoped_word_postings(token: str, search_folders: bool): - postings = word_postings(token) - if search_folders: - return postings - return {index for index in postings if matches_file_token(records[index], token)} - - -def matches_file_query(record: dict, tokens, fuzzy_tokens) -> bool: - file_tokens = tokenize(record.get("_file_lower", "")) if fuzzy_tokens else [] - return all(matches_file_token(record, token) or ( - token in fuzzy_tokens and any(is_fuzzy_match(token, word) for word in file_tokens) - ) for token in tokens) - - -def build_word_index(): - global word_index, vocab_by_len - word_index = {} - for idx, rec in enumerate(records): - file_name = rec.get("File", "") - folders = rec.get("Folder", []) - for tok in tokenize(" ".join([file_name] + folders)): - word_index.setdefault(tok, set()).add(idx) - vocab_by_len = {} - for word in word_index: - vocab_by_len.setdefault(len(word), []).append(word) - - -def build_vocab_by_len(): - global vocab_by_len - vocab_by_len = {} - for word in word_index: - vocab_by_len.setdefault(len(word), []).append(word) - - -def _advance_reader_indexes(limit=READER_INDEX_BATCH_RECORDS, budget=READER_INDEX_BATCH_SECONDS): - global reader_record_indices, repo_reader_record_indices - global reader_record_key_indices, reader_source_id_indices, _reader_indexes_ready - global _reader_index_build - with _reader_indexes_lock: - if _reader_indexes_ready: - return True - if _reader_index_build is None or _reader_index_build["generation"] != _data_generation: - _reader_index_build = {"generation": _data_generation, "records": records, "next": 0, - "indices": [], "repos": {}, "keys": {}, "ids": {}} - build = _reader_index_build - deadline = time.perf_counter() + budget - end = min(len(build["records"]), build["next"] + limit) - while build["next"] < end: - idx = build["next"] - rec = build["records"][idx] - repo = rec.get("Repo", "") - build["keys"][build_reader_asset_key(rec)] = idx - source_id = reader_short_id(rec.get("Link") or build_record_link(rec)) - build["ids"].setdefault(source_id, []).append(idx) - if rec.get("_ext_lower", "") in READER_EXTENSIONS: - build["indices"].append(idx) - build["repos"].setdefault(repo, []).append(idx) - build["next"] += 1 - if time.perf_counter() >= deadline: - break - if build["next"] < len(build["records"]): - return False - if build["generation"] != _data_generation or build["records"] is not records: - _reader_index_build = None - return False - reader_record_indices = array('I', build["indices"]) - repo_reader_record_indices = {key: array('I', indices) for key, indices in build["repos"].items()} - reader_record_key_indices = build["keys"] - reader_source_id_indices = build["ids"] - _reader_indexes_ready = True - _reader_index_build = None - return True - - -def _build_reader_indexes(): - while not _advance_reader_indexes(len(records), float("inf")): - pass - - -def ensure_reader_indexes(): - global _reader_indexes_ready - if _reader_indexes_ready: - return - _build_reader_indexes() - - -async def ensure_reader_indexes_async(): - """Join the shared background build, or start one for an early Reader request.""" - if _reader_indexes_ready: - return - task = getattr(app.state, "reader_index_build_task", None) - if task is None or task.done(): - async def build(): - while not _reader_indexes_ready: - executor = getattr(app.state, "search_executor", None) - if executor is None: - await asyncio.to_thread(_advance_reader_indexes) - else: - try: - await executor.run(_advance_reader_indexes, background=True) - except SearchBusy: - await asyncio.sleep(.02) - continue - await asyncio.sleep(0) - task = asyncio.create_task(build()) - app.state.reader_index_build_task = task - await asyncio.shield(task) - - -async def warm_reader_indexes(): - """Prepare Reader routing during a quiet period without delaying startup.""" - await asyncio.sleep(READER_INDEX_WARM_DELAY_SECONDS) - while not _reader_indexes_ready: - while time.monotonic() - last_user_request_at < READER_INDEX_IDLE_SECONDS: - await asyncio.sleep(READER_INDEX_IDLE_SECONDS) - try: - await ensure_reader_indexes_async() - except SearchBusy: - await asyncio.sleep(READER_INDEX_IDLE_SECONDS) - - -def build_indexes(build_words=True, build_reader=True): - global _reader_index_build - global reader_source_id_indices, _search_generation - global _data_generation, word_index, folder_index, extension_counts, repo_counts, repo_list, extension_list, repo_extension_counts, repo_records_map, sorted_by_name, sorted_by_size, repo_sorted_by_name, repo_sorted_by_size, txt_record_indices, repo_txt_record_indices, reader_record_indices, repo_reader_record_indices, reader_record_key_indices, _reader_indexes_ready - _data_generation += 1 - _search_generation = None - _search_order_cache.clear() - _search_anchor_cache.clear() - _search_cache.clear() - word_index = {} - folder_index = {} - extension_counts = {} - repo_extension_counts = {} - repo_counts = {} - repo_records_map = {} - repo_sorted_by_name = {} - repo_sorted_by_size = {} - txt_record_indices = [] - repo_txt_record_indices = {} - reader_record_indices = [] - repo_reader_record_indices = {} - reader_record_key_indices = {} - reader_source_id_indices = {} - _reader_indexes_ready = False - _reader_index_build = None - for idx, rec in enumerate(records): - repo = rec.get("Repo", "") - repo_counts[repo] = repo_counts.get(repo, 0) + 1 - ext = (rec.get("Extension") or "").lower() - if ext: - extension_counts[ext] = extension_counts.get(ext, 0) + 1 - repo_ext = repo_extension_counts.setdefault(repo, {}) - repo_ext[ext] = repo_ext.get(ext, 0) + 1 - file_name = rec.get("File", "") - folders = rec.get("Folder", []) - if repo not in folder_index: - folder_index[repo] = {} - for depth in range(len(folders) + 1): - folder_path = "/".join(folders[:depth]) if depth > 0 else "" - folder_index[repo].setdefault(folder_path, []).append(idx) - repo_records_map.setdefault(repo, []).append(idx) - if rec.get("HasTxt"): - txt_record_indices.append(idx) - repo_txt_record_indices.setdefault(repo, []).append(idx) - folders_lower = [f.lower() for f in (folders or [])] - rec["_file_lower"] = file_name.lower() - rec["_folders_lower"] = folders_lower - rec["_folder_text"] = " ".join(folders_lower) - rec["_folder_path_lower"] = "/".join(folders_lower) - rec["_folder_path"] = "/".join(folders) - rec["_ext_lower"] = ext - rec["_repo_lower"] = repo.lower() - repo_list = sorted( - [{"name": r, "count": c} for r, c in repo_counts.items()], - key=lambda x: x["name"], - ) - if build_words: - build_word_index() - else: - word_index = {} - global vocab_by_len - vocab_by_len = {} - extension_list = sorted(extension_counts.keys()) - all_indices = list(range(len(records))) - sorted_by_name = sorted(all_indices, key=lambda i: (records[i].get("File") or "").lower()) - sorted_by_size = sorted(all_indices, key=lambda i: ( - -(records[i].get("Size") if isinstance(records[i].get("Size"), (int, float)) else 0), - (records[i].get("File") or "").lower(), - )) - for repo, repo_indices in repo_records_map.items(): - repo_sorted_by_name[repo] = sorted(repo_indices, key=lambda i: (records[i].get("File") or "").lower()) - repo_sorted_by_size[repo] = sorted(repo_indices, key=lambda i: ( - -(records[i].get("Size") if isinstance(records[i].get("Size"), (int, float)) else 0), - (records[i].get("File") or "").lower(), - )) - # These complete, ordered postings are read-only after publication. - # Packed IDs avoid retaining a Python pointer (and often an int) per slot. - sorted_by_name = array('I', sorted_by_name) - sorted_by_size = array('I', sorted_by_size) - txt_record_indices = array('I', txt_record_indices) - for mapping in (repo_records_map, repo_sorted_by_name, repo_sorted_by_size, - repo_txt_record_indices, *folder_index.values()): - for key, indices in mapping.items(): - mapping[key] = array('I', indices) - if build_reader: - _build_reader_indexes() - -def search_data_generation(raw: bytes, index_paths) -> str: - digest = hashlib.sha256() - digest.update(SEARCH_SEMANTICS_VERSION.encode()) - digest.update(str(sys.version_info[:3]).encode()) - digest.update(jieba.__version__.encode()) - digest.update(hashlib.sha256(raw).digest()) - for path in index_paths: - # Index content matters too; file timestamps and process IDs do not. - digest.update(path.name.encode()) - digest.update(hashlib.sha256(path.read_bytes()).digest() if path.exists() else b"missing") - return "api:" + digest.hexdigest() - - -def load_data(): - global records, folder_tree_data, folder_browser_data, ngram_postings, ngram_posting_buffers, word_index, _search_generation - start = time.perf_counter() - stage_start = start - timings = {} - if DATA_PATH.exists(): - raw = gzip.decompress(DATA_PATH.read_bytes()) - records = decode_search_payload(json.loads(raw.decode("utf-8"))) - else: - print("⚠ 数据文件不存在") - records = [] - return - if FOLDER_TREE_PATH.exists(): - folder_tree_data = decode_folder_tree_data(json.loads(gzip.decompress(FOLDER_TREE_PATH.read_bytes()).decode("utf-8"))) - else: - folder_tree_data = {} - if FOLDER_BROWSER_PATH.exists(): - folder_browser_data = decode_folder_browser_data(json.loads(gzip.decompress(FOLDER_BROWSER_PATH.read_bytes()).decode("utf-8"))) - else: - folder_browser_data = {} - timings["data_ms"] = round((time.perf_counter() - stage_start) * 1000, 2) - print(f"📖 已加载 {len(records)} 条记录 ({time.perf_counter() - start:.2f}s)") - stage_start = time.perf_counter() - # Reader ID/pool indexes are built on first Reader use; normal search does - # not need to scan every record to prepare them during cold startup. - build_indexes(build_words=False, build_reader=False) - digest = records_digest() - timings["metadata_ms"] = round((time.perf_counter() - stage_start) * 1000, 2) - stage_start = time.perf_counter() - word_index = load_word_index(WORD_INDEX_PATH, len(records), digest) - word_index_source = "generated" if word_index else "rebuilt" - if word_index: - build_vocab_by_len() - else: - build_word_index() - timings["words_ms"] = round((time.perf_counter() - stage_start) * 1000, 2) - stage_start = time.perf_counter() - loaded_ngrams = { - width: load_ngram_index(path, width, len(records), digest) - for width, path in NGRAM_PATHS.items() - } - ngram_postings = {width: loaded[0] for width, loaded in loaded_ngrams.items()} - ngram_posting_buffers = {width: loaded[1] for width, loaded in loaded_ngrams.items()} - timings["ngrams_ms"] = round((time.perf_counter() - stage_start) * 1000, 2) - stage_start = time.perf_counter() - _search_generation = search_data_generation(raw, [WORD_INDEX_PATH, *NGRAM_PATHS.values()]) - timings["fingerprint_ms"] = round((time.perf_counter() - stage_start) * 1000, 2) - print(f"🧭 已加载词索引: {len(word_index)}") - print(f"🔎 已加载 ngram 索引: 2={len(ngram_postings[2])}, 3={len(ngram_postings[3])}") - build_initial_payloads() - precompute_injected_html() - del raw - stage_start = time.perf_counter() - # A generated postings file avoids corpus tokenization, but query tokenization - # still needs jieba's dictionary. Prepare it before accepting the first query, - # after releasing the decoded source and finishing index-loading temporaries. - jieba.initialize() - timings["tokenizer_ms"] = round((time.perf_counter() - stage_start) * 1000, 2) - timings["total_ms"] = round((time.perf_counter() - start) * 1000, 2) - print("search_performance " + json.dumps({"stage": "startup", "word_index": word_index_source, **timings})) - -def score_record(rec_idx, query_tokens, search_folders=True): - rec = records[rec_idx] - score = 0 - file_name = rec.get("_file_lower", "") - repo = rec.get("_repo_lower", "") - folder_text = rec.get("_folder_text", "") - for tok in query_tokens: - if tok in file_name: - score += 3 - if search_folders and tok in folder_text: - score += 2 - if tok in repo: - score += 1 - return score - -def apply_filters(indices, repos=None, extensions=None, folders=None, min_size=None, max_size=None, folder_match_mode="prefix"): - repos_set = set(repos) if repos else None - extensions_lower = {e.lower() for e in extensions} if extensions else None - folder_set = {f.strip("/") for f in folders} if folders else None - if folder_match_mode != "exact" and folder_set and "" in folder_set: - # Unlike the Worker, an empty API prefix includes the complete scope. - folder_set = None - folder_prefix = next(iter(folder_set)) + "/" if folder_set and len(folder_set) == 1 and folder_match_mode != "exact" else None - result = [] - for idx in indices: - rec = records[idx] - if repos_set and rec.get("Repo") not in repos_set: - continue - if extensions_lower: - if rec.get("_ext_lower", "") not in extensions_lower: - continue - if folder_set: - rec_path = rec["_folder_path"] if "_folder_path" in rec else "/".join(rec.get("Folder", []) or []) - matched = rec_path in folder_set - if not matched and folder_match_mode != "exact": - if folder_prefix is not None: - matched = rec_path.startswith(folder_prefix) - else: - end = rec_path.rfind("/") - while end > 0: - if rec_path[:end] in folder_set: - matched = True - break - end = rec_path.rfind("/", 0, end) - if not matched: - continue - size = rec.get("Size") - if size and isinstance(size, (int, float)) and size > 0: - if min_size is not None and size < min_size: - continue - if max_size is not None and size > max_size: - continue - result.append(idx) - return result - -def public_record(record: dict) -> dict: - item = { - key: value - for key, value in record.items() - if isinstance(key, str) and not key.startswith("_") - } - item.setdefault("Repo", "") - item.setdefault("File", "") - item.setdefault("Extension", "") - item.setdefault("Folder", []) - item.setdefault("Size", "") - item.setdefault("HasTxt", False) - item["Link"] = record.get("Link") or build_record_link(record) - item["Path"] = record.get("Path") or build_record_path_url(record) - return item - - -def trim_results(raw): - keys = ("Repo", "File", "Extension", "Link", "Path", "Folder", "Size", "HasTxt") - return [ - {key: public.get(key, "" if key != "Folder" else []) for key in keys} - for public in (public_record(record) for record in raw) - ] - -def sort_key_name(idx: int): - return (records[idx].get("File") or "").lower() - -def sort_key_size(idx: int): - return ( - -(records[idx].get("Size") if isinstance(records[idx].get("Size"), (int, float)) else 0), - (records[idx].get("File") or "").lower(), - ) - -def paginate_indices(indices: Sequence[int], page: int, page_size: int) -> tuple[int, list[dict]]: - total = len(indices) - start = (page - 1) * page_size - return total, [records[i] for i in indices[start:start + page_size]] - -def get_initial_indices(repos=None) -> Sequence[int]: - if repos: - indices = [] - for repo in dict.fromkeys(repos): - indices.extend(repo_records_map.get(repo, [])) - return indices - return range(len(records)) - -def search_generation() -> str: - return _search_generation or f"api:{_search_instance}:{_data_generation}" - - -def build_response(results: list[dict], total: int, page: int, page_size: int) -> dict: - return { - "results": trim_results(results), - "total": total, - "page": page, - "page_size": page_size, - "generation": search_generation(), - } - - -def trim_initial_results(raw: list[dict]) -> list[dict]: - keys = ("Repo", "File", "Extension", "Folder", "Size", "HasTxt") - return [ - {key: record.get(key, [] if key == "Folder" else "") for key in keys} - for record in raw - ] - -def build_initial_payload(repo: str | None = None) -> dict: - if repo: - indices = repo_records_map.get(repo, []) - result_records = [records[i] for i in indices[:INITIAL_PAGE_SIZE]] - mode = "repo" - total = len(indices) - else: - result_records = records[:INITIAL_PAGE_SIZE] - mode = "global" - total = len(records) - return { - "mode": mode, - "repo": repo, - "sort": "relevance", - "page": 1, - "page_size": INITIAL_PAGE_SIZE, - "total": total, - "results": trim_initial_results(result_records), - "generation": search_generation(), - } - -def build_initial_payloads(): - global initial_payload_global, initial_payload_by_repo - initial_payload_global = build_initial_payload(None) - initial_payload_by_repo = { - repo: build_initial_payload(repo) - for repo in repo_records_map - } - -def precompute_injected_html(): - global injected_html_cache - index_path = Path("static/index.html") - if not index_path.exists(): - injected_html_cache = {} - return - html = index_path.read_text(encoding="utf-8") - injected_html_cache = {None: inject_initial_payload(html, None)} - for repo in repo_records_map: - injected_html_cache[repo] = inject_initial_payload(html, repo.split("/")[-1]) - -def load_sidebar_payload(path: Path): - if not path.exists() or not path.is_file(): - return None - try: - return json.loads(path.read_text(encoding="utf-8")) - except Exception: - return None - -def build_global_bootstrap(): - sidebar = load_sidebar_payload(SIDEBAR_DATA_PATH / "global.json") or {} - return { - "generation": _data_generation, - "repos": repo_list, - "extensions": [{"name": name, "count": extension_counts[name]} for name in sorted(extension_counts)], - "random_txt": {"available": bool(txt_record_indices), "count": len(txt_record_indices)}, - "sidebar": sidebar, - } - -def inject_initial_payload(html: str, repo_short: str | None = None) -> str: - repo = f"VoiceOfML/{repo_short}" if repo_short else None - payload = initial_payload_by_repo.get(repo) if repo else initial_payload_global - if not payload: - return html - script = ( - '" - ) - app_script = re.search(r'', html) - if app_script: - return html[:app_script.start()] + script + "\n" + app_script.group(0) + html[app_script.end():] - return html.replace("", script + "\n") - -def apply_mixed_folder_filters(indices, self_folders=None, subtree_folders=None): - self_set = {str(path).strip("/") for path in (self_folders or [])} - subtree_set = {str(path).strip("/") for path in (subtree_folders or []) if str(path).strip("/")} - if not self_set and not subtree_set: - return indices - result = [] - for idx in indices: - rec = records[idx] - rec_folders = rec.get("Folder", []) or [] - rec_path = "/".join(rec_folders) - if rec_path in self_set: - result.append(idx) - continue - matched = False - for depth in range(1, len(rec_folders) + 1): - prefix = "/".join(rec_folders[:depth]) - if prefix in subtree_set: - matched = True - break - if matched: - result.append(idx) - return result - -def search(q="", repos=None, extensions=None, folders=None, min_size=None, max_size=None, page=1, page_size=100, search_folders=True, sort="relevance", exact=False, folder_match_mode="prefix", folder_selfs=None, folder_subtrees=None, anchor_id=None): - global _search_logged_generation - started = time.perf_counter() - q = q.strip() - order_key = _search_order_key(q, repos, extensions, folders, min_size, max_size, - search_folders, sort, exact, folder_match_mode, - folder_selfs, folder_subtrees) - key = order_key + (page, page_size, anchor_id) - cached_page = _search_cache.get(key) - cache_hit = bool(cached_page and time.monotonic() - cached_page[0] < SEARCH_CACHE_TTL_SECONDS) - result = _cached(_search_cache, key, - lambda: _search_uncached(q, repos, extensions, folders, - min_size, max_size, page, page_size, - search_folders, sort, exact, - folder_match_mode, folder_selfs, - folder_subtrees, anchor_id), - SEARCH_CACHE_TTL_SECONDS, 500) - cached_order = _search_order_cache.get(order_key) - if cached_order is not None and time.monotonic() - cached_order[0] < SEARCH_ORDER_CACHE_TTL_SECONDS: - _search_order_cache[order_key] = (time.monotonic(), cached_order[1]) - _search_order_cache.move_to_end(order_key) - duration_ms = (time.perf_counter() - started) * 1000 - first = _search_logged_generation != _data_generation - if first or duration_ms >= 250: - _search_logged_generation = _data_generation - # Operational timing only; never log queries, paths, anchors or URLs. - print("search_performance " + json.dumps({"stage": "search", "first": first, - "duration_ms": round(duration_ms, 2), "page_cache_hit": cache_hit})) - return result - - -def _search_order_key(q, repos, extensions, folders, min_size, max_size, search_folders, - sort, exact, folder_match_mode, folder_selfs, folder_subtrees): - return (_data_generation, q.strip(), tuple(repos or ()), tuple(extensions or ()), - tuple(folders or ()), min_size, max_size, search_folders, sort, exact, - folder_match_mode, tuple(folder_selfs or ()), tuple(folder_subtrees or ())) - - -def _get_search_order(q, repos, extensions, folders, min_size, max_size, search_folders, - sort, exact, folder_match_mode, folder_selfs, folder_subtrees): - key = _search_order_key(q, repos, extensions, folders, min_size, max_size, - search_folders, sort, exact, folder_match_mode, - folder_selfs, folder_subtrees) - now = time.monotonic() - for old_key, (created, _) in list(_search_order_cache.items()): - if now - created >= SEARCH_ORDER_CACHE_TTL_SECONDS: - del _search_order_cache[old_key] - cached = _search_order_cache.get(key) - if cached is not None: - _search_order_cache[key] = (now, cached[1]) - _search_order_cache.move_to_end(key) - return cached[1] - indices = _search_order_uncached(q, repos, extensions, folders, min_size, max_size, - search_folders, sort, exact, folder_match_mode, - folder_selfs, folder_subtrees) - # Oversized queries still return all results; only caching is skipped. - packed = indices if isinstance(indices, array) else array('I', indices) - size = len(packed) * packed.itemsize - if size <= SEARCH_ORDER_CACHE_BYTES_MAX: - while _search_order_cache and (len(_search_order_cache) >= SEARCH_ORDER_CACHE_MAX or - sum(len(v[1]) * v[1].itemsize for v in _search_order_cache.values()) + size > SEARCH_ORDER_CACHE_BYTES_MAX): - _search_order_cache.popitem(last=False) - _search_order_cache[key] = (now, packed) - return packed - - -def _find_search_anchor(indices, anchor_id): - owner = id(indices) - cached = _search_anchor_cache.get(owner) - if cached is not None and cached[0]() is not indices: - del _search_anchor_cache[owner] - cached = None - if cached is not None and anchor_id in cached[1]: - cached[1].move_to_end(anchor_id) - _search_anchor_cache.move_to_end(owner) - return cached[1][anchor_id] - anchor_repo, separator, anchor_path = anchor_id.partition('\0') - index = -1 - if separator: - for offset, idx in enumerate(indices): - rec = records[idx] - repo = str(rec.get('Repo', '')) - if repo == anchor_repo: - matches = build_record_relative_path(rec) == anchor_path - elif '\0' in repo and anchor_id.startswith(f'{repo}\0'): - # Preserve raw-key matching for injected repository names. - matches = f'{repo}\0{build_record_relative_path(rec)}' == anchor_id - else: - continue - if matches: - index = offset - break - if len(anchor_id) <= SEARCH_ANCHOR_KEY_MAX: - lookups = cached[1] if cached is not None else OrderedDict() - lookups[anchor_id] = index - while len(lookups) > SEARCH_ANCHOR_PER_ORDER_MAX: - lookups.popitem(last=False) - # Do not keep a complete order alive after its normal cache eviction. - _search_anchor_cache[owner] = (weakref.ref(indices), lookups) - _search_anchor_cache.move_to_end(owner) - while len(_search_anchor_cache) > SEARCH_ANCHOR_ORDER_MAX: - _search_anchor_cache.popitem(last=False) - return index - - -def _search_uncached(q="", repos=None, extensions=None, folders=None, min_size=None, max_size=None, page=1, page_size=100, search_folders=True, sort="relevance", exact=False, folder_match_mode="prefix", folder_selfs=None, folder_subtrees=None, anchor_id=None): - indices = _get_search_order(q, repos, extensions, folders, min_size, max_size, - search_folders, sort, exact, folder_match_mode, - folder_selfs, folder_subtrees) - total, results = paginate_indices(indices, page, page_size) - response = build_response(results, total, page, page_size) - if anchor_id is not None: - response['anchor_index'] = _find_search_anchor(indices, anchor_id) - return response - - -def _search_order_uncached(q="", repos=None, extensions=None, folders=None, min_size=None, max_size=None, search_folders=True, sort="relevance", exact=False, folder_match_mode="prefix", folder_selfs=None, folder_subtrees=None): - q = q.strip() - - if exact and q: - matched_indices = [] - _q_lower = q.lower() - _is_match = compile_exact_matcher(q) - candidate_indices = get_initial_indices(repos) - if set(q) <= {"*"}: - # A star-only pattern matches every string, including an empty field. - matched_indices = list(candidate_indices) - elif "*" not in q and "?" not in q: - candidates = literal_candidates(_q_lower, repos) - if candidates is not None: - candidate_indices = sorted(candidates) - if not matched_indices: - for idx in candidate_indices: - rec = records[idx] - file_name = rec.get("_file_lower", "") - folder_path = rec.get("_folder_path_lower", "") - repo_name = rec.get("_repo_lower", "") - if _is_match(file_name) or _is_match(repo_name) or (search_folders and _is_match(folder_path)): - matched_indices.append(idx) - if folder_match_mode == "mixed": - filtered = apply_mixed_folder_filters(matched_indices, folder_selfs, folder_subtrees) - filtered = apply_filters(filtered, None, extensions, None, min_size, max_size) - else: - filtered = apply_filters(matched_indices, None, extensions, folders, min_size, max_size, folder_match_mode) - if sort == "name": - filtered.sort(key=sort_key_name) - elif sort == "size": - filtered.sort(key=sort_key_size) - return filtered - if not q: - has_record_filters = extensions or folders or min_size is not None or max_size is not None or folder_match_mode == "mixed" - can_use_precomputed = not has_record_filters and (not repos or len(repos) == 1) - if can_use_precomputed: - if repos and len(repos) == 1: - repo = repos[0] - if sort == "name": - indices = repo_sorted_by_name.get(repo, []) - elif sort == "size": - indices = repo_sorted_by_size.get(repo, []) - else: - indices = repo_records_map.get(repo, []) - else: - if sort == "name": - indices = sorted_by_name - elif sort == "size": - indices = sorted_by_size - else: - indices = range(len(records)) - return indices - else: - indices = get_initial_indices(repos) - if folder_match_mode == "mixed": - filtered = apply_mixed_folder_filters(indices, folder_selfs, folder_subtrees) - filtered = apply_filters(filtered, repos, extensions, None, min_size, max_size) - else: - filtered = apply_filters(indices, repos, extensions, folders, min_size, max_size, folder_match_mode) - if sort == "name": - filtered.sort(key=sort_key_name) - elif sort == "size": - filtered.sort(key=sort_key_size) - return filtered - query_tokens = tokenize(q) - fuzzy_candidate_limit = 200 if len(query_tokens) == 1 else None - - posting_cache = {} - def query_postings(token): - if token not in posting_cache: - posting_cache[token] = scoped_word_postings(token, search_folders=search_folders) - return posting_cache[token] - - exact_constraint = None - for tok in query_tokens: - if tok not in word_index: - continue - postings = query_postings(tok) - if not postings: - continue - exact_constraint = set(postings) if exact_constraint is None else exact_constraint.intersection(postings) - token_candidates = [] - fuzzy_tokens = set() - for tok in query_tokens: - candidates = query_postings(tok) if tok in word_index else set() - if not candidates: - fuzzy_tokens.add(tok) - fuzzy_candidates = set() - if exact_constraint is not None: - for idx in sorted(exact_constraint): - rec = records[idx] - record_tokens = tokenize(" ".join([rec.get("File", ""), - *((rec.get("Folder", []) or []) if search_folders else [])])) - if any(is_fuzzy_match(tok, word) for word in record_tokens): - fuzzy_candidates.add(idx) - if fuzzy_candidate_limit is not None and len(fuzzy_candidates) >= fuzzy_candidate_limit: - break - else: - tok_len = len(tok) - for delta in (-2, -1, 0, 1, 2): - for vocab_word in vocab_by_len.get(tok_len + delta, []): - if is_fuzzy_match(tok, vocab_word): - fuzzy_candidates.update(query_postings(vocab_word)) - if fuzzy_candidate_limit is not None and len(fuzzy_candidates) >= fuzzy_candidate_limit: - break - if fuzzy_candidate_limit is not None and len(fuzzy_candidates) >= fuzzy_candidate_limit: - break - candidates = fuzzy_candidates - if not candidates: - token_candidates = [] - break - token_candidates.append(candidates) - if token_candidates: - token_candidates.sort(key=len) - matched = set(token_candidates[0]) - for candidates in token_candidates[1:]: - matched.intersection_update(candidates) - if not matched: - break - all_matched = list(matched) - else: - all_matched = [] - literal_query = q.lower() - if literal_query: - literal_matches = [] - initial_indices = get_initial_indices(repos) - candidates = literal_candidates(literal_query, repos) - if candidates is not None: - initial_indices = sorted(candidates) - skip = set(all_matched) - for idx in initial_indices: - if skip and idx in skip: - continue - rec = records[idx] - file_name = rec.get("_file_lower", "") - folder_path = rec.get("_folder_path_lower", "") - repo_name = rec.get("_repo_lower", "") - if literal_query in file_name or literal_query in repo_name or (search_folders and literal_query in folder_path): - literal_matches.append(idx) - if literal_matches: - all_matched.extend(idx for idx in literal_matches if idx not in skip) - if not search_folders and query_tokens and all_matched: - all_matched = [idx for idx in all_matched - if matches_file_query(records[idx], query_tokens, fuzzy_tokens)] - if folder_match_mode == "mixed": - filtered = apply_mixed_folder_filters(all_matched, folder_selfs, folder_subtrees) - filtered = apply_filters(filtered, repos, extensions, None, min_size, max_size) - else: - filtered = apply_filters(all_matched, repos, extensions, folders, min_size, max_size, folder_match_mode) - if sort == "name": - filtered.sort(key=sort_key_name) - return filtered - if sort == "size": - filtered.sort(key=sort_key_size) - return filtered - scored = [(idx, score_record(idx, query_tokens, search_folders)) for idx in filtered] - if sort == "relevance": - scored.sort(key=lambda x: (-x[1], x[0])) - return [idx for idx, _ in scored] - -def build_filter_folder_tree(repo: str) -> list[dict]: - if repo in folder_tree_data: - return folder_tree_data[repo] - if repo not in folder_index: - return [] - repo_name_short = repo.split("/")[-1] - tree_root = { - "name": repo_name_short, - "path": "", - "children": [], - "count": 0, - "hasDirectFiles": False, - "hasChildren": False, - "showSelfToggle": False, - "isRoot": True, - } - node_map = {"": tree_root} - for path, idx_list in folder_index[repo].items(): - if path == "": - tree_root["count"] = len(idx_list) - continue - parts = path.split("/") - for depth in range(1, len(parts) + 1): - key = "/".join(parts[:depth]) - if key not in node_map: - parent_key = "/".join(parts[:depth - 1]) if depth > 1 else "" - parent = node_map[parent_key] - node = { - "name": parts[depth - 1], - "path": key, - "children": [], - "count": 0, - "hasDirectFiles": False, - "hasChildren": False, - "showSelfToggle": False, - } - node_map[key] = node - parent["children"].append(node) - node_map[path]["count"] = len(idx_list) - direct_file_dirs = set() - for idx in repo_records_map.get(repo, []): - rec = records[idx] - folders = rec.get("Folder", []) or [] - direct_file_dirs.add("/".join(folders)) - for path_key, node in node_map.items(): - node["hasDirectFiles"] = path_key in direct_file_dirs - node["children"].sort(key=lambda child: child["name"]) - node["hasChildren"] = len(node["children"]) > 0 - node["showSelfToggle"] = bool(path_key and node["hasDirectFiles"] and node["hasChildren"]) - return [tree_root] - -def get_folder_contents(repo: str, path: str) -> dict: - if repo in folder_browser_data: - repo_browser = folder_browser_data.get(repo, {}) or {} - if path in repo_browser: - return enrich_browser_entry(repo, path, repo_browser[path]) - if path == "" and "" in repo_browser: - return enrich_browser_entry(repo, path, repo_browser[""]) - path_parts = [p for p in path.strip("/").split("/") if p] - path_depth = len(path_parts) - repo_folders = folder_index.get(repo) - candidate_indices = (repo_folders.get("/".join(path_parts), []) - if repo_folders is not None else repo_records_map.get(repo, [])) - - def is_under(rec_folders, prefix): - return rec_folders[:len(prefix)] == prefix - matching = [records[i] for i in candidate_indices - if is_under(records[i].get("Folder", []) or [], path_parts)] - subfolder_names = set() - subfolder_counts = {} - for rec in matching: - folders = rec.get("Folder", []) or [] - if len(folders) > path_depth: - name = folders[path_depth] - subfolder_names.add(name) - subfolder_counts[name] = subfolder_counts.get(name, 0) + 1 - result_folders = [] - for fname in sorted(subfolder_names): - full_path = "/".join(path_parts + [fname]) if path_parts else fname - result_folders.append({"name": fname, "path": full_path, "count": subfolder_counts.get(fname, 0)}) - direct_files = [] - for rec in matching: - folders = rec.get("Folder", []) or [] - if len(folders) == path_depth: - direct_files.append({ - "name": rec.get("File", ""), - "ext": rec.get("Extension", ""), - "link": rec.get("Link") or build_record_link(rec), - "path": rec.get("Path") or build_record_path_url(rec), - "hasTxt": rec.get("HasTxt", False), - "size": rec.get("Size", ""), - }) - direct_files.sort(key=lambda f: f["name"]) - return {"folders": result_folders, "files": direct_files, "current_path": path} - -DOWNLOAD_CONNECTION_WARM_INTERVAL_SECONDS = 30 - - -async def warm_download_connection(app: FastAPI): - # Use the download pool, but never queue speculative work behind real transfers. - semaphore = app.state.upstream_semaphore - if semaphore.locked(): - return - try: - async with semaphore: - async with app.state.http_session.head( - "https://huggingface.co/robots.txt", - allow_redirects=False, - timeout=aiohttp.ClientTimeout(total=4, connect=3, sock_read=3), - ): - pass - except (aiohttp.ClientError, asyncio.TimeoutError): - # A failed warmup must not affect ping or the later download attempt. - pass - - -def schedule_download_connection_warm(app: FastAPI): - state = app.state - if not getattr(state, "download_warm_enabled", False): - return - task = state.download_warm_task - now = time.monotonic() - if task is not None and not task.done(): - return - if now - state.download_warm_at < DOWNLOAD_CONNECTION_WARM_INTERVAL_SECONDS: - return - state.download_warm_at = now - state.download_warm_task = asyncio.create_task(warm_download_connection(app)) - - -async def warm_txt_proxy_loop(app: FastAPI): - while True: - await asyncio.sleep(TXT_WARM_INTERVAL_SECONDS) - if time.monotonic() - last_user_request_at < TXT_WARM_IDLE_SECONDS: - continue - pool = txt_record_indices - if not pool: - continue - rec = records[random.choice(pool)] - remote_path = build_txt_relative_path(rec) - remote_url = TXT_SPACE_RAW_BASE + "/" + quote(remote_path, safe="/") - try: - async with app.state.http_session.get( - remote_url, - headers={"Range": "bytes=0-0", "Accept-Encoding": "identity"}, - timeout=aiohttp.ClientTimeout(total=15, connect=5, sock_read=5), - ) as response: - await response.content.read(1) - except asyncio.CancelledError: - raise - except Exception as e: - print(f"txt 预热异常: {e}") -@asynccontextmanager - -async def lifespan(app: FastAPI): - global last_user_request_at - load_data() - app.state.search_executor = SearchExecutor() - app.state.reader_index_build_task = None - app.state.reader_index_warm_task = None - app.state.http_session = aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=300, connect=15), - connector=aiohttp.TCPConnector(keepalive_timeout=60, ttl_dns_cache=300), - headers={"User-Agent": "VoiceOfML-Search/1.0"}, - auto_decompress=True, - ) - app.state.upstream_semaphore = asyncio.Semaphore(8) - app.state.download_warm_enabled = True - app.state.download_warm_task = None - app.state.download_warm_at = float("-inf") - app.state.reader_assets_lock = asyncio.Lock() - app.state.reader_assets_task = None - initialize_reader_bucket_state(app) - initialize_reader_archive_state(app) - app.state.reader_range_cache = OrderedDict() - app.state.reader_range_cache_bytes = 0 - app.state.reader_range_flights = {} - app.state.reader_range_flight_owners = set() - last_user_request_at = time.monotonic() - app.state.reader_index_warm_task = asyncio.create_task(warm_reader_indexes()) - app.state.txt_warm_task = asyncio.create_task(warm_txt_proxy_loop(app)) - try: - yield - finally: - if app.state.reader_index_warm_task is not None: - app.state.reader_index_warm_task.cancel() - await asyncio.gather(app.state.reader_index_warm_task, return_exceptions=True) - app.state.reader_index_warm_task = None - if app.state.reader_index_build_task is not None: - app.state.reader_index_build_task.cancel() - await asyncio.gather(app.state.reader_index_build_task, return_exceptions=True) - app.state.reader_index_build_task = None - await app.state.search_executor.close() - del app.state.search_executor - if app.state.reader_assets_task is not None: - app.state.reader_assets_task.cancel() - await asyncio.gather(app.state.reader_assets_task, return_exceptions=True) - app.state.reader_assets_task = None - app.state.download_warm_enabled = False - if app.state.download_warm_task is not None: - app.state.download_warm_task.cancel() - await asyncio.gather(app.state.download_warm_task, return_exceptions=True) - app.state.download_warm_task = None - app.state.txt_warm_task.cancel() - try: - await app.state.txt_warm_task - except asyncio.CancelledError: - pass - await shutdown_reader_bucket_state(app) - await shutdown_reader_archive_state(app) - await shutdown_reader_range_flights() - app.state.reader_range_cache.clear() - app.state.reader_range_cache_bytes = 0 - await app.state.http_session.close() -app = FastAPI(title="VoiceOfML Search", version="2.0", lifespan=lifespan) -app.add_middleware(GZipMiddleware, minimum_size=500) -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], - expose_headers=["Accept-Ranges", "Content-Range", "Content-Length", "ETag", "Last-Modified", "Server-Timing"], -) - -@app.middleware("http") -async def track_user_activity(request: Request, call_next): - global last_user_request_at - if request.url.path not in ("/health", "/favicon.ico", "/api/ping"): - last_user_request_at = time.monotonic() - return await call_next(request) - -SearchText = Annotated[str, Field(max_length=256)] -SearchListItem = Annotated[str, Field(max_length=512)] -SearchList = Annotated[list[SearchListItem], Field(max_length=100)] -FolderSearchList = Annotated[list[SearchListItem], Field(max_length=10000)] - - -class SearchRequest(BaseModel): - q: SearchText = "" - repos: Optional[SearchList] = None - extensions: Optional[SearchList] = None - folders: Optional[FolderSearchList] = None - folder_match_mode: Literal["prefix", "exact", "mixed"] = "prefix" - folder_selfs: Optional[FolderSearchList] = None - folder_subtrees: Optional[FolderSearchList] = None - min_size: Optional[int] = Field(default=None, ge=0) - max_size: Optional[int] = Field(default=None, ge=0) - page: int = Field(default=1, ge=1) - page_size: int = Field(default=100, ge=1, le=500) - search_folders: bool = True - sort: Literal["relevance", "name", "size"] = "relevance" - exact: bool = False - anchor_id: Optional[str] = Field(default=None, max_length=16384) - - -def normalize_search_list(values: list[str] | None, lowercase: bool = False) -> list[str] | None: - if not values: - return None - return list(dict.fromkeys(value.strip().lower() if lowercase else value.strip() for value in values)) - - -async def build_search_api_response(body: SearchRequest, repos: list[str] | None) -> Response: - params = body.model_dump(exclude={"repos"}) - for name in ("extensions", "folders", "folder_selfs", "folder_subtrees"): - params[name] = normalize_search_list(params[name], lowercase=name == "extensions") - compute = functools.partial(search, repos=repos, **params) - try: - executor = getattr(app.state, "search_executor", None) - if executor is None: - # Direct endpoint tests without ASGI lifespan own a temporary pool. - async with SearchExecutor() as executor: - payload, queued_ms, search_ms = await executor.run(compute) - else: - payload, queued_ms, search_ms = await executor.run(compute) - except SearchBusy: - return JSONResponse({"error": "搜索繁忙,请稍后重试"}, status_code=503, - headers={"Retry-After": "1"}) - started = time.perf_counter() - assets = await get_reader_assets(wait=False) - assets_ms = (time.perf_counter() - started) * 1000 - started = time.perf_counter() - key = ("search", _data_generation, reader_assets_revision, READER_ASSET_ROUTING_CONTRACT_VERSION, tuple(repos or ()), - json.dumps(params, sort_keys=True, ensure_ascii=False)) - response = cached_json_response(key, lambda: add_reader_assets(payload, assets)) - response.headers["Server-Timing"] = ( - f"queue;dur={queued_ms:.2f}, search;dur={search_ms:.2f}, " - f"reader_assets;dur={assets_ms:.2f}, response;dur={(time.perf_counter() - started) * 1000:.2f}") - return response - - -@app.post("/api/search") -async def api_search(body: SearchRequest): - return await build_search_api_response(body, normalize_search_list(body.repos)) - - -@app.post("/api/search/{repo_name}") -async def api_search_repo(repo_name: str, body: SearchRequest): - full_repo = f"VoiceOfML/{repo_name}" - if full_repo not in repo_counts: - return JSONResponse({"error": f"仓库 {full_repo} 不存在", "results": [], "total": 0}, status_code=404) - return await build_search_api_response(body, [full_repo]) - - -@app.get("/api/repos") - -def api_repos(): - return cached_json_response(("repos", _data_generation), lambda: repo_list, METADATA_CACHE_HEADERS) -@app.get("/api/reader-assets") - -async def api_reader_assets(): - assets = await get_reader_assets() - cache_control = "public, max-age=300" if assets.get("f") else "no-store" - return JSONResponse(assets, headers={"Cache-Control": cache_control}) -@app.get("/api/reader-v3-status") -async def api_reader_v3_status(): - assets = await get_reader_assets() - metadata = assets.get("v3") - return JSONResponse({"version": 1, "surface": "hf", "active": metadata is not None, - **(metadata or {})}, headers={"Cache-Control": "no-store"}) -@app.get("/api/extensions") - -def api_extensions(repo: Optional[str] = Query(default=None)): - def build(): - if repo: - full_repo = f"VoiceOfML/{repo}" - ext_count = repo_extension_counts.get(full_repo, {}) - else: - ext_count = extension_counts - sorted_exts = sorted(ext_count.keys()) - return [{"name": e, "count": ext_count[e]} for e in sorted_exts] - return cached_json_response(("extensions", _data_generation, repo or ""), build, METADATA_CACHE_HEADERS) -@app.get("/api/folders/{repo_name}") - -def api_folders(repo_name: str): - full_repo = f"VoiceOfML/{repo_name}" - if full_repo not in repo_counts: - return JSONResponse({"error": f"仓库 {full_repo} 不存在"}, status_code=404) - return cached_json_response(("folders", _data_generation, full_repo), lambda: build_filter_folder_tree(full_repo)) -@app.get("/api/folders/{repo_name}/contents") - -def api_folder_contents(repo_name: str, path: str = Query(default="")): - full_repo = f"VoiceOfML/{repo_name}" - if full_repo not in repo_counts: - return JSONResponse({"error": f"仓库 {full_repo} 不存在"}, status_code=404) - return cached_json_response(("folder_contents", _data_generation, full_repo, path), lambda: get_folder_contents(full_repo, path)) -@app.get("/api/random") - -def api_random(repo: Optional[str] = Query(default=None)): - if repo: - full_repo = f"VoiceOfML/{repo}" - repo_indices = repo_records_map.get(full_repo, []) - if not repo_indices: - return JSONResponse({"error": "无可用记录"}, status_code=404) - rec = public_record(records[random.choice(repo_indices)]) - else: - if not records: - return JSONResponse({"error": "无可用记录"}, status_code=404) - rec = public_record(random.choice(records)) - return JSONResponse(rec) - -@app.get("/api/random-txt") -def api_random_txt(repo: Optional[str] = Query(default=None)): - if repo: - full_repo = f"VoiceOfML/{repo}" - pool = repo_txt_record_indices.get(full_repo, []) - else: - pool = txt_record_indices - if not pool: - return JSONResponse({"error": "无可用 txt"}, status_code=404) - return JSONResponse(public_record(records[random.choice(pool)])) - -@app.get("/api/random-txt/status") -def api_random_txt_status(repo: Optional[str] = Query(default=None)): - if repo: - full_repo = f"VoiceOfML/{repo}" - count = len(repo_txt_record_indices.get(full_repo, [])) - else: - count = len(txt_record_indices) - return JSONResponse({"available": count > 0, "count": count}, headers=METADATA_CACHE_HEADERS) - -def converted_reader_indices(assets: dict, full_repo: str | None = None) -> dict[int, dict]: - global reader_join_cache_key, reader_join_cache - ensure_reader_indexes() - cache_key = (_data_generation, reader_assets_revision, READER_ASSET_ROUTING_CONTRACT_VERSION) - if reader_join_cache_key != cache_key: - converted = {} - for key, asset in assets.get("f", {}).items(): - if not isinstance(asset, dict) or asset.get("s") != 2: - continue - index = reader_record_key_indices.get(key) - if index is not None: - converted[index] = asset - by_repo = {} - for index, asset in converted.items(): - repo = records[index].get("Repo", "") - by_repo.setdefault(repo, {})[index] = asset - reader_join_cache = {"all": converted, "repos": by_repo} - reader_join_cache_key = cache_key - if full_repo: - return reader_join_cache["repos"].get(full_repo, {}) - return reader_join_cache["all"] - - -async def get_reader_pool(repo: str | None = None) -> tuple[list[int], dict[int, dict]]: - await ensure_reader_indexes_async() - full_repo = f"VoiceOfML/{repo}" if repo else None - original = repo_reader_record_indices.get(full_repo, []) if full_repo else reader_record_indices - # Random selection uses the complete last published snapshot while refresh - # proceeds; a cold process must still await its first asset generation. - assets = await get_reader_assets(wait=not bool(reader_assets_cache.get("f"))) - converted = converted_reader_indices(assets, full_repo) - pool = dict.fromkeys(original) - pool.update(dict.fromkeys(converted)) - return list(pool), converted - - -@app.get("/api/random-reader") -async def api_random_reader(repo: Optional[str] = Query(default=None)): - started = time.perf_counter() - pool, converted = await get_reader_pool(repo) - if not pool: - return JSONResponse({"error": "无可读文档"}, status_code=404) - index = random.choice(pool) - rec = public_record(records[index]) - asset = converted.get(index) - if asset: - rec = enrich_reader_record(rec, asset) - return JSONResponse(rec, headers={"Server-Timing": f"reader_select;dur={(time.perf_counter() - started) * 1000:.2f}"}) - -@app.get("/api/random-reader/status") -async def api_random_reader_status(repo: Optional[str] = Query(default=None)): - pool, _ = await get_reader_pool(repo) - count = len(pool) - return JSONResponse({"available": count > 0, "count": count}, headers=METADATA_CACHE_HEADERS) - -@app.get("/api/bootstrap") -def api_bootstrap(): - return JSONResponse( - cached_payload(("bootstrap", _data_generation), build_global_bootstrap), - headers=METADATA_CACHE_HEADERS, - ) - -@app.get("/api/ping") -async def api_ping(): - schedule_download_connection_warm(app) - return PlainTextResponse(status_code=204, headers={"Cache-Control": "no-store"}) - - -READER_UPSTREAM_RESPONSE_HEADERS = ( - "Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", - "ETag", "Last-Modified", "Cache-Control", -) -READER_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable" -READER_ISOLATION_HEADERS = { - "Content-Security-Policy": "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'", - "X-Content-Type-Options": "nosniff", -} -READER_DOCUMENT_MAX_BYTES = 512 * 1024 * 1024 -READER_RESOURCE_MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 -READER_RESOURCE_MAX_BYTES = 64 * 1024 * 1024 -READER_BUCKET_CACHE_TTL_SECONDS = 3600 -READER_BUCKET_CACHE_MAX_BYTES = 32 * 1024 * 1024 -READER_BUCKET_CACHE_MAX_ITEM_BYTES = 8 * 1024 * 1024 -READER_BUCKET_MAX_CONCURRENT_READS = 8 -READER_BUCKET_MAX_INFLIGHT = 32 -READER_OCR_CACHE_MAX_BYTES = 32 * 1024 * 1024 -READER_OCR_CACHE_MAX_ITEMS = 4 -READER_ARCHIVE_MAX_ENTRIES = 20_000 -READER_ARCHIVE_MAX_EXPANDED_BYTES = 1536 * 1024 * 1024 -READER_ARCHIVE_MAX_COMPRESSION_RATIO = 200 -READER_ARCHIVE_MAX_CENTRAL_DIRECTORY_BYTES = 8 * 1024 * 1024 -READER_ARCHIVE_MAX_METADATA_BYTES = 16 * 1024 * 1024 -READER_ARCHIVE_CACHE_TTL_SECONDS = 900 -READER_ARCHIVE_CACHE_MAX_BYTES = 32 * 1024 * 1024 -READER_ARCHIVE_CACHE_MAX_ENTRIES = 4 -READER_ARCHIVE_MAX_CONCURRENT_LOADS = 2 -READER_ARCHIVE_MAX_INFLIGHT = 16 -READER_ARCHIVE_MAX_WORKERS = 2 -READER_ARCHIVE_MEMBER_RE = re.compile(r"^[^/\x00]+(?:/[^/\x00]+)*(?:/)?$") -READER_RANGE_CACHE_TTL_SECONDS = 900 -READER_RANGE_CACHE_MAX_BYTES = 32 * 1024 * 1024 -READER_RANGE_CACHE_MAX_ITEM_BYTES = 2 * 1024 * 1024 -READER_RANGE_CACHE_MAX_ENTRIES = 256 -READER_RANGE_MAX_FLIGHTS = 32 - - -class ReaderArchiveLimitError(ValueError): - pass - - -class ReaderArchiveInvalidError(ValueError): - pass - - -class ReaderDocumentLimitError(ValueError): - pass - - -def validate_reader_archive(archive: bytes) -> None: - if len(archive) > READER_RESOURCE_MAX_ARCHIVE_BYTES: - raise ReaderArchiveLimitError("阅读文件过大") - try: - # Use the stdlib's EOCD/ZIP64 parser before ZipFile allocates its directory. - end_record = zipfile._EndRecData(io.BytesIO(archive)) - if not end_record: - raise ReaderArchiveInvalidError("阅读文件不是有效 ZIP") - central_directory_size = end_record[zipfile._ECD_SIZE] - directory_offset = end_record[zipfile._ECD_OFFSET] - entry_count = end_record[zipfile._ECD_ENTRIES_TOTAL] - if (end_record[zipfile._ECD_DISK_NUMBER] != 0 - or end_record[zipfile._ECD_DISK_START] != 0 - or end_record[zipfile._ECD_ENTRIES_THIS_DISK] != entry_count - or len(end_record[zipfile._ECD_COMMENT]) != end_record[zipfile._ECD_COMMENT_SIZE] - or (end_record[zipfile._ECD_SIGNATURE] != zipfile.stringEndArchive64 - and (central_directory_size == 0xffffffff or directory_offset == 0xffffffff))): - raise ReaderArchiveInvalidError("阅读文件元数据无效") - if central_directory_size > READER_ARCHIVE_MAX_CENTRAL_DIRECTORY_BYTES: - raise ReaderArchiveLimitError("阅读文件元数据过大") - if entry_count > READER_ARCHIVE_MAX_ENTRIES: - raise ReaderArchiveLimitError("阅读文件条目过多") - if directory_offset + central_directory_size > len(archive): - raise ReaderArchiveInvalidError("阅读文件元数据无效") - with zipfile.ZipFile(io.BytesIO(archive)) as book_zip: - infos = book_zip.infolist() - if len(infos) > READER_ARCHIVE_MAX_ENTRIES: - raise ReaderArchiveLimitError("阅读文件条目过多") - - if len(infos) != entry_count: - raise ReaderArchiveInvalidError("阅读文件条目数无效") - central_directory_offset = book_zip.start_dir - archive_comment = book_zip.comment - if ( - not isinstance(central_directory_size, int) - or isinstance(central_directory_size, bool) - or central_directory_size < 0 - or central_directory_size > READER_ARCHIVE_MAX_CENTRAL_DIRECTORY_BYTES - or not isinstance(central_directory_offset, int) - or isinstance(central_directory_offset, bool) - or central_directory_offset < 0 - or central_directory_offset > len(archive) - or central_directory_size > len(archive) - central_directory_offset - or not isinstance(archive_comment, bytes) - ): - raise ReaderArchiveInvalidError("阅读文件元数据无效") - - expanded = 0 - compressed = 0 - metadata_bytes = len(archive_comment) - for info in infos: - filename = info.orig_filename - extra = info.extra - comment = info.comment - if not isinstance(filename, str) or not isinstance(extra, bytes) or not isinstance(comment, bytes): - raise ReaderArchiveInvalidError("阅读文件元数据无效") - name = filename.replace("\\", "/") - name_parts = name.split("/") - if (not name or name.startswith("/") or "\x00" in name - or any(part in {".", ".."} for part in name_parts) - or any(not part for part in name_parts[:-1]) - or not READER_ARCHIVE_MEMBER_RE.fullmatch(name)): - raise ReaderArchiveInvalidError("阅读文件包含不安全路径") - try: - filename_bytes = len(filename.encode("utf-8", "surrogatepass")) - except (UnicodeError, AttributeError) as exc: - raise ReaderArchiveInvalidError("阅读文件元数据无效") from exc - metadata_bytes += 46 + filename_bytes + len(extra) + len(comment) - if metadata_bytes > READER_ARCHIVE_MAX_METADATA_BYTES: - raise ReaderArchiveLimitError("阅读文件元数据过大") - - file_size = info.file_size - compressed_size = info.compress_size - header_offset = info.header_offset - if ( - not isinstance(file_size, int) - or isinstance(file_size, bool) - or file_size < 0 - or not isinstance(compressed_size, int) - or isinstance(compressed_size, bool) - or compressed_size < 0 - or compressed_size > len(archive) - or not isinstance(header_offset, int) - or isinstance(header_offset, bool) - or header_offset < 0 - or header_offset >= len(archive) - or header_offset >= central_directory_offset - ): - raise ReaderArchiveInvalidError("阅读文件条目无效") - expanded += file_size - compressed += compressed_size - if expanded > READER_ARCHIVE_MAX_EXPANDED_BYTES: - raise ReaderArchiveLimitError("阅读文件展开后过大") - if file_size and (compressed_size == 0 or file_size > compressed_size * READER_ARCHIVE_MAX_COMPRESSION_RATIO): - raise ReaderArchiveLimitError("阅读文件压缩比过高") - - if compressed == 0: - if expanded: - raise ReaderArchiveLimitError("阅读文件压缩比过高") - elif expanded > compressed * READER_ARCHIVE_MAX_COMPRESSION_RATIO: - raise ReaderArchiveLimitError("阅读文件压缩比过高") - if compressed > len(archive): - raise ReaderArchiveInvalidError("阅读文件条目无效") - except (ReaderArchiveLimitError, ReaderArchiveInvalidError): - raise - except Exception as exc: - raise ReaderArchiveInvalidError("阅读文件不是有效 ZIP") from exc - - -READER_RESOURCE_TYPES = { - "json": "application/json", "xhtml": "application/xhtml+xml", "html": "text/html", "txt": "text/plain", - "md": "text/markdown", "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "pdf": "application/pdf", "epub": "application/epub+zip", - "css": "text/css", "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", - "gif": "image/gif", "bmp": "image/bmp", "webp": "image/webp", "jxl": "image/jxl", "svg": "image/svg+xml", "ttf": "font/ttf", - "otf": "font/otf", "woff": "font/woff", "woff2": "font/woff2", "mp3": "audio/mpeg", - "wav": "audio/wav", "m4a": "audio/mp4", "flac": "audio/flac", "mpga": "audio/mpeg", - "mp4": "video/mp4", "mov": "video/quicktime", "swf": "application/x-shockwave-flash", "gz": "application/gzip", -} -def initialize_reader_bucket_state(target_app: FastAPI) -> None: - target_app.state.reader_bucket_cache = OrderedDict() - target_app.state.reader_bucket_cache_bytes = 0 - target_app.state.reader_bucket_inflight = {} - target_app.state.reader_bucket_workers = set() - target_app.state.reader_bucket_semaphore = asyncio.Semaphore(READER_BUCKET_MAX_CONCURRENT_READS) - target_app.state.reader_ocr_cache = OrderedDict() - target_app.state.reader_ocr_cache_bytes = 0 - - -async def shutdown_reader_bucket_state(target_app: FastAPI) -> None: - tasks = list(target_app.state.reader_bucket_inflight.values()) - target_app.state.reader_bucket_inflight.clear() - for task in tasks: - task.cancel() - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - workers = list(target_app.state.reader_bucket_workers) - if workers: - await asyncio.gather(*workers, return_exceptions=True) - target_app.state.reader_bucket_cache.clear() - target_app.state.reader_bucket_cache_bytes = 0 - target_app.state.reader_ocr_cache.clear() - target_app.state.reader_ocr_cache_bytes = 0 - - -def is_reader_bucket_cacheable(path: str) -> bool: - return ( - path.endswith("/page-manifest.json") or path.endswith("/ocr-manifest.json") - or path.endswith("/ocr/book-text.json.gz") or path.endswith("/pages/page-000001.webp") - or (path.startswith("objects/") and path.rsplit("/", 1)[-1].startswith("document.")) - or (path.startswith("chapters/ebook/") and any( - path.endswith(suffix) for suffix in ( - "/chapter-manifest.json", "/epub-search-index.json.gz", - ))) - ) - - -def reader_bucket_cache_control(path: str) -> str: - # Page paths contain the source digest and an optional asset version, so a - # generated image can be cached immutably even though the manifest moves. - if re.search(r"/pages/page-[0-9]{6}\.(?:webp|jxl)$", path): - return "public, max-age=31536000, immutable" - if re.fullmatch(r"objects/[0-9a-f]{2}/[0-9a-f]{64}/[0-9a-f]{16}/(?:preview|text)/.+", path): - return "public, max-age=31536000, immutable" - if path.startswith("objects/") and path.rsplit("/", 1)[-1].startswith("document."): - return "public, max-age=31536000, immutable" - if "/ebook-chapters/" in path: - return "public, max-age=31536000, immutable" - return "public, max-age=3600" - - -def reader_bucket_for_path(path: str) -> str: - if (path.startswith(("documents/", "chapters/", "media/", "pages/image/", "native/"))): - return "vomebook/reader-assets-v2" - return "vomebook/pdf-pages-v2" - - -def reader_bucket_source_url(path: str) -> str: - bucket = reader_bucket_for_path(path) - return f"https://huggingface.co/buckets/{bucket}/resolve/{quote(path, safe='/')}" - - -def validate_reader_bucket_redirect_url(url: str, source_url: str) -> str: - validate_download_url(url) - if urlparse(url).hostname == "huggingface.co" and url != source_url: - raise ValueError("上游重定向超出允许的阅读资源范围") - return url - - -def read_reader_bucket_resource(path: str, max_bytes: int = READER_RESOURCE_MAX_BYTES) -> bytes: - filesystem = HfFileSystem() - bucket = reader_bucket_for_path(path) - with filesystem.open(f"hf://buckets/{bucket}/{path}", "rb") as stream: - payload = stream.read(max_bytes + 1) - if len(payload) > max_bytes: - raise ValueError("阅读资源过大") - return payload - - -async def get_reader_bucket_resource(path: str, max_bytes: int = READER_RESOURCE_MAX_BYTES) -> bytes: - state = app.state - cache_key = path - if is_reader_bucket_cacheable(path): - cached = state.reader_bucket_cache.get(cache_key) - if cached: - cached_at, payload = cached - if time.monotonic() - cached_at < READER_BUCKET_CACHE_TTL_SECONDS: - state.reader_bucket_cache.move_to_end(cache_key) - return payload - state.reader_bucket_cache.pop(cache_key) - state.reader_bucket_cache_bytes -= len(payload) - - task = state.reader_bucket_inflight.get(cache_key) - if task is None: - if len(state.reader_bucket_inflight) >= READER_BUCKET_MAX_INFLIGHT: - raise RuntimeError("too many reader bucket requests") - async def load() -> bytes: - try: - payload = await run_reader_bucket_read(path, max_bytes) - if is_reader_bucket_cacheable(path) and len(payload) <= READER_BUCKET_CACHE_MAX_ITEM_BYTES: - previous = state.reader_bucket_cache.pop(cache_key, None) - if previous: - state.reader_bucket_cache_bytes -= len(previous[1]) - state.reader_bucket_cache[cache_key] = (time.monotonic(), payload) - state.reader_bucket_cache_bytes += len(payload) - while state.reader_bucket_cache_bytes > READER_BUCKET_CACHE_MAX_BYTES: - _, (_, evicted) = state.reader_bucket_cache.popitem(last=False) - state.reader_bucket_cache_bytes -= len(evicted) - return payload - finally: - if state.reader_bucket_inflight.get(cache_key) is asyncio.current_task(): - state.reader_bucket_inflight.pop(cache_key, None) - - task = asyncio.create_task(load()) - task.add_done_callback(lambda done: None if done.cancelled() else done.exception()) - state.reader_bucket_inflight[cache_key] = task - return await asyncio.shield(task) - - -async def run_reader_bucket_read(path: str, max_bytes: int) -> bytes: - state = app.state - semaphore = state.reader_bucket_semaphore - await semaphore.acquire() - worker = asyncio.create_task(asyncio.to_thread(read_reader_bucket_resource, path, max_bytes)) - state.reader_bucket_workers.add(worker) - - def finished(done): - # Cancelling an async caller cannot stop its filesystem thread. - state.reader_bucket_workers.discard(done) - semaphore.release() - if not done.cancelled(): - done.exception() - - worker.add_done_callback(finished) - return await asyncio.shield(worker) - - -def parse_reader_ocr_book(raw: bytes) -> tuple[list[dict], int]: - """Decode the single v2 book text index and its layout mappings.""" - with gzip.GzipFile(fileobj=io.BytesIO(raw)) as stream: - decoded = stream.read(READER_RESOURCE_MAX_BYTES + 1) - if len(decoded) > READER_RESOURCE_MAX_BYTES: - raise ValueError("PDF OCR text index is too large") - payload = json.loads(decoded) - pages = payload.get("pages") if isinstance(payload, dict) else None - if (not isinstance(pages, list) or len(pages) > 200000 - or payload.get("version") != 2 or payload.get("kind") != "pdf-book-text" - or payload.get("complete") is not True - or payload.get("offset_unit") != "unicode-codepoint" - or payload.get("page_count") != len(pages) - or not re.fullmatch(r"[0-9a-f]{64}", str(payload.get("source_sha256", "")))): - raise ValueError("invalid PDF OCR text index") - output = [] - total_bytes = 0 - previous = 0 - for item in pages: - if (not isinstance(item, dict) or not isinstance(item.get("page"), int) - or item["page"] < 1 or item["page"] <= previous - or not isinstance(item.get("text"), str)): - raise ValueError("invalid PDF OCR text page") - text = item["text"] - total_bytes += len(text.encode("utf-8")) - if total_bytes > READER_RESOURCE_MAX_BYTES: - raise ValueError("PDF OCR text index is too large") - layout = item.get("layout") - spans = item.get("text_spans") - if (not isinstance(layout, dict) or layout.get("offset_unit") != "unicode-codepoint" - or not isinstance(spans, list)): - raise ValueError("invalid PDF text layout mapping") - previous_span_end = 0 - for span in spans: - box = span.get("box") if isinstance(span, dict) else None - if (not isinstance(span, dict) or not isinstance(span.get("start"), int) - or not isinstance(span.get("end"), int) or span["start"] < previous_span_end - or span["end"] <= span["start"] or span["end"] > len(text) - or not isinstance(box, list) or len(box) != 4 - or any(not isinstance(value, (int, float)) or value < 0 or value > 1 for value in box)): - raise ValueError("invalid PDF text span") - previous_span_end = span["end"] - output.append({"page": item["page"], "text": text, "folded": text.casefold(), - "layout": layout, "spans": spans}) - previous = item["page"] - return output, total_bytes - - -async def get_reader_ocr_book(path: str) -> list[dict]: - state = app.state - if not hasattr(state, "reader_ocr_cache"): - state.reader_ocr_cache = OrderedDict() - state.reader_ocr_cache_bytes = 0 - cached = state.reader_ocr_cache.get(path) - if cached: - cached_at, pages, size = cached - if time.monotonic() - cached_at < READER_BUCKET_CACHE_TTL_SECONDS: - state.reader_ocr_cache.move_to_end(path) - return pages - state.reader_ocr_cache.pop(path) - state.reader_ocr_cache_bytes -= size - raw = await get_reader_bucket_resource(path) - pages, size = await asyncio.to_thread(parse_reader_ocr_book, raw) - if size <= READER_OCR_CACHE_MAX_BYTES: - previous = state.reader_ocr_cache.pop(path, None) - if previous: - state.reader_ocr_cache_bytes -= previous[2] - state.reader_ocr_cache[path] = (time.monotonic(), pages, size) - state.reader_ocr_cache_bytes += size - while (len(state.reader_ocr_cache) > READER_OCR_CACHE_MAX_ITEMS - or state.reader_ocr_cache_bytes > READER_OCR_CACHE_MAX_BYTES): - _, (_, _, evicted_size) = state.reader_ocr_cache.popitem(last=False) - state.reader_ocr_cache_bytes -= evicted_size - return pages - - -@app.api_route("/api/reader-bucket-resource", methods=["GET", "HEAD"]) -async def api_reader_bucket_resource(path: str = Query(...), request: Request = None): - resource_path = unquote(unquote(path)).replace("\\", "/").lstrip("/") - if not READER_BUCKET_PATH_RE.fullmatch(resource_path): - return JSONResponse({"error": "不允许的阅读资源路径"}, status_code=400, - headers={"Cache-Control": "no-store"}) - suffix = resource_path.rsplit(".", 1)[-1].lower() - media_type = READER_RESOURCE_TYPES.get(suffix, "application/octet-stream") - # Uncached body resources must release transport on close, including office, - # text and chapter bodies. Shared metadata/index cache reads remain bounded. - if request is not None and (suffix == "pdf" or media_type.startswith(("audio/", "video/", "image/")) - or not is_reader_bucket_cacheable(resource_path)): - source_url = reader_bucket_source_url(resource_path) - return await stream_reader_document( - request, source_url, - url_validator=lambda url: validate_reader_bucket_redirect_url(url, source_url), - validation_scope=(validate_reader_bucket_redirect_url, source_url), - cache_control=reader_bucket_cache_control(resource_path), - max_bytes=(None if media_type.startswith(("audio/", "video/")) else - READER_DOCUMENT_MAX_BYTES if suffix == "pdf" else READER_RESOURCE_MAX_BYTES), - media_type=media_type, - ) - try: - payload = await get_reader_bucket_resource(resource_path) - except FileNotFoundError: - return JSONResponse({"error": "阅读资源不存在"}, status_code=404, - headers={"Cache-Control": "no-store"}) - except Exception: - return JSONResponse({"error": "阅读资源加载失败"}, status_code=502, - headers={"Cache-Control": "no-store"}) - return Response(content=b"" if request is not None and request.method == "HEAD" else payload, - media_type=media_type, - headers={**READER_ISOLATION_HEADERS, - "Cache-Control": reader_bucket_cache_control(resource_path), - "Content-Length": str(len(payload)), "Content-Encoding": "identity"}) - - -def initialize_reader_archive_state(target_app: FastAPI) -> None: - target_app.state.reader_archive_cache = OrderedDict() - target_app.state.reader_archive_inflight = {} - target_app.state.reader_archive_workers = set() - target_app.state.reader_archive_load_semaphore = asyncio.Semaphore(READER_ARCHIVE_MAX_CONCURRENT_LOADS) - target_app.state.reader_archive_worker_semaphore = asyncio.Semaphore(READER_ARCHIVE_MAX_WORKERS) - - -async def shutdown_reader_archive_state(target_app: FastAPI) -> None: - tasks = list(target_app.state.reader_archive_inflight.values()) - for task in tasks: - task.cancel() - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - workers = list(target_app.state.reader_archive_workers) - if workers: - await asyncio.gather(*workers, return_exceptions=True) - target_app.state.reader_archive_inflight.clear() - target_app.state.reader_archive_cache.clear() - - -async def run_reader_archive_worker(function, *args): - state = app.state - semaphore = state.reader_archive_worker_semaphore - await semaphore.acquire() - task = asyncio.create_task(asyncio.to_thread(function, *args)) - state.reader_archive_workers.add(task) - - def finished(done): - # Cancelling a caller cannot stop its thread or free that thread's slot. - state.reader_archive_workers.discard(done) - semaphore.release() - if not done.cancelled(): - done.exception() - - task.add_done_callback(finished) - return await asyncio.shield(task) - - -def prepare_reader_archive(raw: bytes | bytearray) -> bytes: - archive = bytes(raw) - if archive.startswith(b"\x1f\x8b"): - try: - with gzip.GzipFile(fileobj=io.BytesIO(archive)) as stream: - archive = stream.read(READER_RESOURCE_MAX_ARCHIVE_BYTES + 1) - except (OSError, EOFError, zlib.error) as exc: - raise ReaderArchiveInvalidError("阅读文件不是有效 ZIP") from exc - validate_reader_archive(archive) - return archive - - -async def get_reader_archive(target_url: str) -> bytes: - state = app.state - cache = state.reader_archive_cache - cached = cache.get(target_url) - if cached: - if time.monotonic() - cached[0] < READER_ARCHIVE_CACHE_TTL_SECONDS: - cache.move_to_end(target_url) - return cached[1] - cache.pop(target_url) - task = state.reader_archive_inflight.get(target_url) - if task is None: - if len(state.reader_archive_inflight) >= READER_ARCHIVE_MAX_INFLIGHT: - raise HTTPException(503, "阅读资源请求过多") - - async def load(): - try: - async with state.reader_archive_load_semaphore: - async with state.upstream_semaphore: - upstream = await open_download_response( - state.http_session, target_url, - aiohttp.ClientTimeout(total=180, connect=15, sock_read=60), - url_validator=validate_reader_redirect_url, - ) - try: - if upstream.status != 200: - raise HTTPException(upstream.status, f"上游阅读文件加载失败: HTTP {upstream.status}") - chunks = bytearray() - async for chunk in upstream.content.iter_chunked(64 * 1024): - chunks.extend(chunk) - if len(chunks) > READER_RESOURCE_MAX_ARCHIVE_BYTES: - raise ReaderArchiveLimitError("阅读文件过大") - finally: - upstream.release() - archive = await run_reader_archive_worker(prepare_reader_archive, chunks) - if len(archive) <= READER_ARCHIVE_CACHE_MAX_BYTES: - cache[target_url] = (time.monotonic(), archive) - cache.move_to_end(target_url) - while (len(cache) > READER_ARCHIVE_CACHE_MAX_ENTRIES - or sum(len(item[1]) for item in cache.values()) > READER_ARCHIVE_CACHE_MAX_BYTES): - cache.popitem(last=False) - return archive - finally: - state.reader_archive_inflight.pop(target_url, None) - - task = asyncio.create_task(load()) - task.add_done_callback(lambda done: None if done.cancelled() else done.exception()) - state.reader_archive_inflight[target_url] = task - return await asyncio.shield(task) - - -def read_reader_archive_member(archive: bytes, resource_path: str) -> bytes: - with zipfile.ZipFile(io.BytesIO(archive)) as book_zip: - names = {name.replace("\\", "/"): name for name in book_zip.namelist()} - info = book_zip.getinfo(names[resource_path]) - if info.file_size > READER_RESOURCE_MAX_BYTES: - raise ReaderArchiveLimitError("阅读资源过大") - return book_zip.read(info) - - -@app.get("/api/reader-resource") -async def api_reader_resource(book: str = Query(...), path: str = Query(...)): - try: - target_url = validate_reader_source_url(book) - except ValueError as exc: - return JSONResponse({"error": str(exc)}, status_code=403) - resource_path = path.replace("\\", "/") - if (not resource_path or len(resource_path) > 512 - or "\x00" in resource_path - or any(part in {"", ".", ".."} for part in resource_path.split("/"))): - return JSONResponse({"error": "不允许的阅读资源路径"}, status_code=400) - try: - archive = await get_reader_archive(target_url) - except HTTPException as exc: - return JSONResponse({"error": exc.detail}, status_code=exc.status_code, headers={"Cache-Control": "no-store"}) - except ReaderArchiveLimitError as exc: - return JSONResponse({"error": str(exc)}, status_code=413) - except ReaderArchiveInvalidError as exc: - return JSONResponse({"error": str(exc)}, status_code=400) - except Exception as exc: - return JSONResponse({"error": str(exc) or "上游阅读文件加载失败"}, status_code=502) - try: - payload = await run_reader_archive_worker(read_reader_archive_member, archive, resource_path) - except KeyError: - return JSONResponse({"error": "阅读资源不存在"}, status_code=404) - except ReaderArchiveLimitError as exc: - return JSONResponse({"error": str(exc)}, status_code=413) - except (zipfile.BadZipFile, EOFError, IndexError, OverflowError, - RuntimeError, NotImplementedError, OSError, zlib.error): - return JSONResponse({"error": "阅读资源损坏"}, status_code=400) - suffix = resource_path.rsplit(".", 1)[-1].lower() if "." in resource_path else "" - return Response(content=payload, media_type=READER_RESOURCE_TYPES.get(suffix, "application/octet-stream"), headers={**READER_ISOLATION_HEADERS, "Cache-Control": "public, max-age=3600"}) - - -@app.get("/api/reader-resolve") -async def api_reader_resolve(id: str = Query(..., min_length=13, max_length=13)): - await ensure_reader_indexes_async() - if not READER_SHORT_ID_RE.fullmatch(id): - return JSONResponse({"error": "无效阅读 ID"}, status_code=400) - # A cold resolution must await conversion metadata; otherwise unsupported - # originals (such as PS) become cached as the Reader's document source. - assets = await get_reader_assets(wait=not bool(reader_assets_cache.get("f"))) - matches = [] - for index in reader_source_id_indices.get(id, []): - record = records[index] - matches.append(reader_resolved_record(record, assets.get("f", {}).get(build_reader_asset_key(record), {}))) - if len({canonical_reader_source_url(match["download"]) for match in matches}) > 1: - return JSONResponse({"error": "阅读 ID 不唯一"}, status_code=409) - matches = matches[:1] - if matches: - return JSONResponse(matches[0], headers={"Cache-Control": - "public, max-age=300" if assets.get("f") else "no-store"}) - return JSONResponse({"error": "阅读文件不存在"}, status_code=404) - - -@app.get("/api/reader-ocr-search") -async def api_reader_ocr_search( - id: str = Query(..., min_length=13, max_length=13), - q: str = Query(..., min_length=1, max_length=256), - page: int = Query(1, ge=1, le=1000000), - page_size: int = Query(50, ge=1, le=100), -): - """Search one PDF's immutable OCR text without loading it into app memory.""" - resolved = await api_reader_resolve(id) - if resolved.status_code != 200: - return resolved - try: - data = json.loads(resolved.body) - manifest_url = str(data.get("ocr_manifest") or "") - parsed = urlparse(manifest_url) - parameters = parse_qs(parsed.query) - manifest_path = parameters.get("path", [""])[0] - if (parsed.path != "/api/reader-bucket-resource" or set(parameters) != {"path"} - or not READER_BUCKET_PATH_RE.fullmatch(manifest_path) - or not manifest_path.endswith("/ocr-manifest.json")): - return JSONResponse({"error": "本书暂无 OCR 索引"}, status_code=404) - manifest = json.loads(await get_reader_bucket_resource(manifest_path)) - book = manifest.get("book_text") if isinstance(manifest, dict) else None - book_path = book.get("path") if isinstance(book, dict) else "" - if (not isinstance(book_path, str) or not READER_BUCKET_PATH_RE.fullmatch(book_path) - or not book_path.endswith("/book-text.json.gz")): - return JSONResponse({"error": "OCR 索引损坏"}, status_code=502) - pages = await get_reader_ocr_book(book_path) - needle = q.casefold() - matches = [] - for item in pages: - page_number, text, folded = item["page"], item["text"], item["folded"] - start = 0 - while True: - found = folded.find(needle, start) - if found < 0: - break - end = found + len(q) - snippet_start = max(0, found - 72) - snippet_end = min(len(text), end + 88) - begin = found - end = found + len(q) - boxes = [span["box"] for span in item["spans"] - if span["end"] > begin and span["start"] < end] - matches.append({ - "page": page_number, "start": found, "length": len(q), - "snippet": text[snippet_start:snippet_end], - "snippet_start": found - snippet_start, "boxes": boxes, - }) - start = end - total = len(matches) - offset = (page - 1) * page_size - return JSONResponse({ - "version": 2, "kind": "pdf-book-text-search", "page": page, "page_size": page_size, "total": total, - "results": matches[offset:offset + page_size], - }, headers={"Cache-Control": "no-store"}) - except (ValueError, TypeError, KeyError, EOFError, gzip.BadGzipFile, zlib.error): - return JSONResponse({"error": "OCR 索引损坏"}, status_code=502) - - -@app.api_route("/api/reader-content", methods=["GET", "HEAD"]) -async def api_reader_content(request: Request, url: Optional[str] = Query(default=None), id: Optional[str] = Query(default=None)): - if id and not url: - resolved = await api_reader_resolve(id) - if resolved.status_code != 200: - return resolved - try: - url = json.loads(resolved.body).get("url") - except (AttributeError, TypeError, ValueError): - return JSONResponse({"error": "阅读文件解析失败"}, status_code=502) - if not url: - return JSONResponse({"error": "缺少阅读文件地址"}, status_code=400) - try: - parsed = urlparse(url) - except ValueError as exc: - return JSONResponse({"error": str(exc)}, status_code=403) - is_bucket_proxy = ( - (not parsed.scheme and not parsed.netloc) - or (parsed.scheme == "https" and parsed.netloc == "voiceofml-search.hf.space") - ) - if is_bucket_proxy and parsed.path == "/api/reader-bucket-resource" and not parsed.fragment: - parameters = parse_qs(parsed.query) - if (set(parameters) != {"path"} or len(parameters["path"]) != 1): - return JSONResponse({"error": "不允许的阅读资源路径"}, status_code=400) - return await api_reader_bucket_resource(parameters["path"][0], request) - bucket_prefix = next((prefix for prefix in ( - "/buckets/vomebook/pdf-pages-v2/resolve/", - "/buckets/vomebook/reader-assets-v2/resolve/", - ) if parsed.path.startswith(prefix)), None) - if (parsed.scheme == "https" and parsed.hostname == "huggingface.co" - and parsed.netloc.lower() in {"huggingface.co", "huggingface.co:443"} - and not parsed.username and not parsed.password and not parsed.query and not parsed.fragment - and bucket_prefix is not None): - resource_path = unquote(parsed.path[len(bucket_prefix):]) - if not READER_BUCKET_PATH_RE.fullmatch(resource_path): - return JSONResponse({"error": "不允许的阅读资源路径"}, status_code=400) - return await api_reader_bucket_resource(resource_path, request) - try: - target_url = validate_reader_source_url(url) - except ValueError as exc: - return JSONResponse({"error": str(exc)}, status_code=403) - source_type = READER_RESOURCE_TYPES.get(unquote(urlparse(target_url).path).rsplit(".", 1)[-1].lower(), "") - if source_type.startswith(("audio/", "video/")): - return await stream_reader_document(request, target_url, media_type=source_type) - return await stream_reader_document(request, target_url) - - -def reader_document_browser_headers(target_url: str, headers: dict, status: int) -> dict: - is_pdf = ( - headers.get("Content-Type", "").split(";", 1)[0].strip().lower() == "application/pdf" - or urlparse(target_url).path.lower().endswith(".pdf") - ) - if status == 206 and is_pdf: - # Keep browser partial bodies separate from the bounded server cache. - # The request-side PDF policy bypasses Chromium's same-URL cache lock. - return {**headers, "Cache-Control": "no-store"} - return headers - - -async def stream_reader_document( - request: Request, target_url: str, *, - url_validator=validate_reader_redirect_url, cache_control: str | None = None, - max_bytes: int | None = None, media_type: str | None = None, validation_scope=None, -): - options = dict(url_validator=url_validator, cache_control=cache_control, - max_bytes=max_bytes, media_type=media_type, validation_scope=validation_scope) - if not callable(getattr(request, "is_disconnected", None)): - return await _stream_reader_document(request, target_url, **options) - - stop_watching = asyncio.Event() - - async def disconnected(): - while not stop_watching.is_set(): - if await request.is_disconnected(): - return - if not stop_watching.is_set(): - await asyncio.sleep(0.1) - - # StreamingResponse handles disconnects after headers; own the wait for - # the upstream slot and headers too, so abandoned books cannot occupy it. - transport = asyncio.create_task(_stream_reader_document(request, target_url, **options)) - watcher = asyncio.create_task(disconnected()) - try: - await asyncio.wait((transport, watcher), return_when=asyncio.FIRST_COMPLETED) - if transport.done(): - return transport.result() - raise asyncio.CancelledError() - finally: - stop_watching.set() - watcher.cancel() - if not transport.done(): - transport.cancel() - await asyncio.gather(transport, watcher, return_exceptions=True) - - -async def _stream_reader_document( - request: Request, target_url: str, *, - url_validator=validate_reader_redirect_url, cache_control: str | None = None, - max_bytes: int | None = None, media_type: str | None = None, validation_scope=None, _share_range: bool = True, -): - # Native media is streamed, not parsed or buffered as a complete document. - # Bound transport memory and idle time instead of rejecting large recordings. - is_media_stream = bool(media_type and media_type.startswith(("audio/", "video/"))) - if max_bytes is None and not is_media_stream: - max_bytes = READER_DOCUMENT_MAX_BYTES - forwarded_headers = { - name: request.headers[name] - for name in ("Range", "If-Range", "If-None-Match", "If-Modified-Since") - if name in request.headers - } - range_header = forwarded_headers.get("Range") - # Conditional requests must reach the origin: a cached partial body cannot - # represent a 200/304 decision made against a different validator. - has_conditionals = any( - name in forwarded_headers for name in ("If-Range", "If-None-Match", "If-Modified-Since") - ) - range_key = ( - (target_url, range_header, max_bytes, media_type) - if request.method == "GET" and range_header and not has_conditionals - else None - ) - if range_key: - if not hasattr(app.state, "reader_range_cache"): - app.state.reader_range_cache = OrderedDict() - app.state.reader_range_cache_bytes = 0 - cached = app.state.reader_range_cache.get(range_key) - if cached: - cached_at, payload, cached_headers = cached - if time.monotonic() - cached_at < READER_RANGE_CACHE_TTL_SECONDS: - app.state.reader_range_cache.move_to_end(range_key) - return Response(content=payload, status_code=206, - headers=reader_proxy_timing( - reader_document_browser_headers(target_url, cached_headers, 206), cache="hit")) - app.state.reader_range_cache.pop(range_key, None) - app.state.reader_range_cache_bytes -= len(payload) - covered = reader_cached_subrange(range_key) - if covered is not None: - payload, cached_headers = covered - return Response(content=payload, status_code=206, headers=reader_proxy_timing( - reader_document_browser_headers(target_url, cached_headers, 206), cache="slice")) - requested = re.fullmatch(r"bytes=([0-9]{1,20})-([0-9]{1,20})", range_header) - if (_share_range and requested and int(requested[1]) <= int(requested[2]) - and int(requested[2]) - int(requested[1]) + 1 <= READER_RANGE_CACHE_MAX_ITEM_BYTES - and (urlparse(target_url).path.lower().endswith(".pdf") or media_type == "application/pdf")): - return await shared_reader_range_response(request, target_url, range_key, dict( - url_validator=url_validator, cache_control=cache_control, - max_bytes=max_bytes, media_type=media_type, validation_scope=validation_scope)) - semaphore = app.state.upstream_semaphore - queued_at = time.monotonic() - await semaphore.acquire() - acquired_at = time.monotonic() - lease = UpstreamLease(semaphore) - try: - upstream = lease.response = await open_download_response( - app.state.http_session, - target_url, - aiohttp.ClientTimeout(total=None if is_media_stream else 180, connect=15, sock_read=60), - method=request.method, - request_headers=forwarded_headers, - url_validator=url_validator, - auto_decompress=False, - ) - headers_at = time.monotonic() - # Range offsets and document limits describe identity bytes, not compressed wire bytes. - if upstream.headers.get("Content-Encoding", "identity").strip().lower() != "identity": - return JSONResponse({"error": "上游阅读文件编码无效"}, status_code=502) - headers = {name: upstream.headers[name] for name in READER_UPSTREAM_RESPONSE_HEADERS if name in upstream.headers} - headers.update(READER_ISOLATION_HEADERS) - headers["Cache-Control"] = cache_control or ( - READER_IMMUTABLE_CACHE_CONTROL - if is_immutable_reader_asset(target_url) - else headers.get("Cache-Control", "public, max-age=300") - ) - # Gecko treats any Content-Encoding header as compressed media and - # disables length/range seeking. GZipMiddleware already excludes media. - if is_media_stream: - headers.pop("Content-Encoding", None) - else: - headers["Content-Encoding"] = "identity" - if media_type: - headers["Content-Type"] = media_type - status = upstream.status - headers = reader_document_browser_headers(target_url, headers, status) - headers = reader_proxy_timing(headers, cache="miss", queued=acquired_at - queued_at, - upstream=headers_at - acquired_at) - declared_length = upstream.headers.get("Content-Length") - length = None - if declared_length is not None: - try: - length = int(declared_length) - except (TypeError, ValueError): - return JSONResponse({"error": "上游阅读文件长度无效"}, status_code=502) - if length < 0 or (max_bytes is not None and length > max_bytes): - return JSONResponse({"error": "阅读文件过大"}, status_code=413) - content_range = upstream.headers.get("Content-Range") - if status == 206: - match = re.fullmatch(r"bytes\s+([0-9]{1,20})-([0-9]{1,20})/([0-9]{1,20})", str(content_range or "")) - if not match: - return JSONResponse({"error": "上游阅读文件范围无效"}, status_code=502) - range_start, range_end, total_length = (int(value) for value in match.groups()) - if (range_start > range_end or range_end >= total_length - or (length is not None and length != range_end - range_start + 1)): - return JSONResponse({"error": "上游阅读文件范围无效"}, status_code=502) - if max_bytes is not None and total_length > max_bytes: - return JSONResponse({"error": "阅读文件过大"}, status_code=413) - elif status == 416: - match = re.fullmatch(r"bytes\s+\*/([0-9]{1,20})", str(content_range or "")) - if match and max_bytes is not None and int(match.group(1)) > max_bytes: - return JSONResponse({"error": "阅读文件过大"}, status_code=413) - if request.method == "HEAD" or status == 304: - return Response(status_code=status, headers=headers) - if status not in (200, 206, 416): - return JSONResponse({"error": f"上游阅读文件加载失败: HTTP {status}"}, status_code=status) - - async def stream_reader_content(): - total = 0 - chunks = [] - cacheable = bool( - range_key and status == 206 and length is not None - and length <= READER_RANGE_CACHE_MAX_ITEM_BYTES - ) - try: - async for chunk in upstream.content.iter_chunked(65536): - total += len(chunk) - if max_bytes is not None and total > max_bytes: - raise ReaderDocumentLimitError("阅读文件过大") - if cacheable: - chunks.append(chunk) - yield chunk - if cacheable and total == length: - payload = b"".join(chunks) - cache = app.state.reader_range_cache - previous = cache.pop(range_key, None) - if previous: - app.state.reader_range_cache_bytes -= len(previous[1]) - cache[range_key] = (time.monotonic(), payload, headers) - app.state.reader_range_cache_bytes += len(payload) - while (app.state.reader_range_cache_bytes > READER_RANGE_CACHE_MAX_BYTES - or len(cache) > READER_RANGE_CACHE_MAX_ENTRIES): - _, (_, evicted, _) = cache.popitem(last=False) - app.state.reader_range_cache_bytes -= len(evicted) - finally: - lease.close() - - return lease.streaming_response(stream_reader_content(), status_code=status, headers=headers) - except Exception as exc: - print(f"阅读代理异常: {exc}") - return JSONResponse({"error": str(exc) or "上游阅读文件加载失败"}, status_code=502) - finally: - if not lease.transferred: - lease.close() - - -@app.api_route("/api/reader-cdn", methods=["GET", "HEAD"]) -async def api_reader_cdn(request: Request, url: str = Query(...)): - """Resolve a public VoiceOfML asset to its short-lived CDN URL. - - The browser can then range-read the CDN directly. Reader clients retain - /api/reader-content as a fallback for networks that cannot reach the CDN. - """ - try: - target_url = validate_reader_source_url(url) - semaphore = app.state.upstream_semaphore - await semaphore.acquire() - lease = UpstreamLease(semaphore) - try: - upstream = lease.response = await open_download_response( - app.state.http_session, - target_url, - aiohttp.ClientTimeout(total=30, connect=10, sock_read=15), - method="HEAD", - url_validator=validate_reader_redirect_url, - auto_decompress=False, - ) - final_url = str(upstream.url) - validate_reader_redirect_url(final_url) - if upstream.status >= 400: - return JSONResponse({"error": f"上游阅读文件加载失败: HTTP {upstream.status}"}, status_code=upstream.status) - return Response(status_code=307, headers={ - "Location": final_url, - "Cache-Control": "no-store", - "Access-Control-Allow-Origin": "*", - }) - finally: - lease.close() - except Exception as exc: - return JSONResponse({"error": str(exc) or "阅读 CDN 解析失败"}, status_code=502) - - -@app.get("/api/download") -async def api_download(file: str = Query(...), link: str = Query(...)): - try: - target_url = validate_voiceofml_source_url(link) - except ValueError: - return JSONResponse({"error": "只允许 HuggingFace 域名"}, status_code=403) - session = app.state.http_session - semaphore = app.state.upstream_semaphore - await semaphore.acquire() - with UpstreamLease(semaphore) as lease: - try: - upstream = lease.response = await open_download_response( - session, - target_url, - aiohttp.ClientTimeout(total=120, connect=15, sock_read=30), - url_validator=validate_voiceofml_redirect_url, - ) - except Exception as e: - print(f"下载代理异常: {e}") - return JSONResponse({"error": str(e) or "上游下载失败"}, status_code=502) - if upstream.status != 200: - return JSONResponse({"error": f"上游下载失败: HTTP {upstream.status}"}, status_code=502) - encoded_filename = quote(sanitize_archive_name(file), safe="") - return lease.streaming_response( - lease.chunks("下载代理异常"), - media_type="application/octet-stream", - headers={ - "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}", - "Cache-Control": "no-cache", - }, - ) - - -@app.get("/api/download/check") -async def api_download_check(link: str = Query(...)): - target_url = link - try: - target_url = validate_voiceofml_source_url(target_url) - except ValueError as e: - return JSONResponse({"error": str(e)}, status_code=403) - session = app.state.http_session - semaphore = app.state.upstream_semaphore - await semaphore.acquire() - upstream = None - try: - upstream = await open_download_response( - session, - target_url, - aiohttp.ClientTimeout(total=30, connect=10, sock_read=10), - method="HEAD", - url_validator=validate_voiceofml_redirect_url, - ) - if upstream.status not in (200, 206): - return JSONResponse({"error": f"上游下载失败: HTTP {upstream.status}"}, status_code=502) - return JSONResponse({"ok": True}) - except Exception as e: - print(f"下载检查异常: {e}") - return JSONResponse({"error": str(e) or "上游下载失败"}, status_code=502) - finally: - if upstream is not None: - upstream.release() - semaphore.release() -TXT_DIR.mkdir(parents=True, exist_ok=True) - -def resolve_safe_txt_path(raw_path: str) -> Path: - decoded_path = unquote(raw_path).replace("\\", "/").strip("/") - normalized = posixpath.normpath(decoded_path) - if normalized in ("", ".") or normalized.startswith("../") or normalized == "..": - raise ValueError("invalid txt path") - target = (TXT_DIR / normalized).resolve() - base = TXT_DIR.resolve() - if target != base and base not in target.parents: - raise ValueError("invalid txt path") - return target - -@app.get("/txt/{txt_path:path}") -async def serve_txt_proxy(txt_path: str): - try: - local_path = resolve_safe_txt_path(txt_path) - except ValueError: - return JSONResponse({"error": "invalid txt path"}, status_code=400) - if local_path.exists() and local_path.is_file(): - return FileResponse(local_path, media_type="text/plain; charset=utf-8") - remote_path = unquote(txt_path).replace("\\", "/").strip("/") - remote_url = TXT_SPACE_RAW_BASE + "/" + quote(remote_path, safe="/") - session = app.state.http_session - with UpstreamLease() as lease: - try: - upstream = lease.response = await open_download_response( - session, - remote_url, - aiohttp.ClientTimeout(total=60, connect=10, sock_read=20), - ) - except Exception as e: - print(f"txt 代理异常: {e}") - return JSONResponse({"error": str(e) or "txt 读取失败"}, status_code=502) - if upstream.status != 200: - status = upstream.status - return JSONResponse({"error": f"txt 不存在: HTTP {status}"}, status_code=404 if status == 404 else 502) - return lease.streaming_response(lease.chunks("txt 代理异常"), media_type="text/plain; charset=utf-8") - -HASHED_ASSET_RE = re.compile(r"\.[0-9a-f]{12}\.(?:js|css|mjs)$") - - -class HashedStaticFiles(StaticFiles): - async def get_response(self, path: str, scope): - response = await super().get_response(path, scope) - if response.status_code == 200 and HASHED_ASSET_RE.search(path): - response.headers["Cache-Control"] = "public, max-age=31536000, immutable" - return response - - -app.mount("/static", HashedStaticFiles(directory="static", html=True), name="static") -app.mount("/icons", StaticFiles(directory="static/icons"), name="icons") -@app.get("/manifest.json") - -def serve_manifest(): - p = Path("static/manifest.json") - if p.exists(): - return JSONResponse(json.loads(p.read_text(encoding="utf-8"))) - return JSONResponse({"error": "manifest not found"}, status_code=404) -@app.get("/sw.js") - -def serve_sw(): - p = Path("static/sw.js") - if p.exists(): - return PlainTextResponse(p.read_text(encoding="utf-8"), media_type="application/javascript") - return PlainTextResponse("", status_code=404) - -@app.get("/data/initial/global.json") -def serve_initial_global(): - return JSONResponse(cached_payload(("initial_global", _data_generation), lambda: initial_payload_global or build_initial_payload(None))) - -@app.get("/data/initial/repos/{repo_name}.json") -def serve_initial_repo(repo_name: str): - full_repo = f"VoiceOfML/{repo_name}" - payload = initial_payload_by_repo.get(full_repo) - if not payload: - return JSONResponse({"error": f"仓库 {full_repo} 不存在"}, status_code=404) - return JSONResponse(cached_payload(("initial_repo", _data_generation, full_repo), lambda: payload)) - -@app.get("/data/initial/manifest.json") -def serve_initial_manifest(): - def build(): - urls = ["/data/initial/global.json"] - for repo in sorted(initial_payload_by_repo): - urls.append(f"/data/initial/repos/{quote(repo.split('/')[-1], safe='')}.json") - return {"urls": urls} - return JSONResponse(cached_payload(("initial_manifest", _data_generation), build)) - -def read_sidebar_json(path: Path): - data = load_sidebar_payload(path) - if data is None: - return JSONResponse({"error": "sidebar data not found"}, status_code=404) - return JSONResponse( - cached_payload(("sidebar_file", _data_generation, str(path)), lambda: data), - headers=METADATA_CACHE_HEADERS, - ) - -@app.get("/data/sidebar/global.json") -def serve_sidebar_global(): - return read_sidebar_json(SIDEBAR_DATA_PATH / "global.json") - -@app.get("/data/sidebar/repos/{repo_name}.json") -def serve_sidebar_repo(repo_name: str): - full_repo = f"VoiceOfML/{repo_name}" - if full_repo not in repo_counts: - return JSONResponse({"error": f"仓库 {full_repo} 不存在"}, status_code=404) - return read_sidebar_json(SIDEBAR_DATA_PATH / "repos" / f"{quote(repo_name, safe='')}.json") - -@app.get("/data/sidebar/manifest.json") -def serve_sidebar_manifest(): - return read_sidebar_json(SIDEBAR_DATA_PATH / "manifest.json") - -@app.get("/") -async def serve_root(): - html = injected_html_cache.get(None) - if html is not None: - return HTMLResponse(html) - index_path = Path("static/index.html") - if index_path.exists(): - return HTMLResponse(inject_initial_payload(index_path.read_text(encoding="utf-8"), None)) - return PlainTextResponse("Frontend shell unavailable", status_code=503) - -@app.get("/{rest_of_path:path}") - -async def serve_spa(rest_of_path: str): - index_path = Path("static/index.html") - if index_path.exists(): - repo_short = rest_of_path.strip("/").split("/")[0] if rest_of_path.strip("/") else None - if repo_short and f"VoiceOfML/{repo_short}" not in repo_counts: - return PlainTextResponse("Repository not found", status_code=404) - cache_key = f"VoiceOfML/{repo_short}" if repo_short else None - html = injected_html_cache.get(cache_key) - if html is not None: - return HTMLResponse(html) - return HTMLResponse(inject_initial_payload(index_path.read_text(encoding="utf-8"), repo_short)) - return PlainTextResponse("Frontend shell unavailable", status_code=503) - -def reader_proxy_timing(headers: dict, *, cache: str, queued: float = 0, upstream: float = 0) -> dict: - return {**headers, "Server-Timing": ( - f'reader_cache;desc="{cache}", reader_queue;dur={queued * 1000:.2f}, ' - f'reader_headers;dur={upstream * 1000:.2f}' - )} - - -def reader_cached_subrange(range_key): - requested = re.fullmatch(r"bytes=([0-9]{1,20})-([0-9]{1,20})", range_key[1]) - if requested is None: - return None - start, end = (int(value) for value in requested.groups()) - if start > end or end - start + 1 > READER_RANGE_CACHE_MAX_ITEM_BYTES: - return None - cache = app.state.reader_range_cache - now = time.monotonic() - for key in reversed(list(cache)): - cached_at, payload, headers = cache[key] - if now - cached_at >= READER_RANGE_CACHE_TTL_SECONDS: - cache.pop(key) - app.state.reader_range_cache_bytes -= len(payload) - continue - if key[0] != range_key[0] or key[2:] != range_key[2:]: - continue - match = re.fullmatch(r"bytes ([0-9]+)-([0-9]+)/([0-9]+)", headers.get("Content-Range", "")) - if match is None: - continue - cached_start, cached_end, total = (int(value) for value in match.groups()) - final_end = min(end, total - 1) - if cached_start <= start <= final_end <= cached_end and len(payload) == cached_end - cached_start + 1: - cache.move_to_end(key) - body = payload[start - cached_start:final_end - cached_start + 1] - return body, {**headers, "Content-Range": f"bytes {start}-{final_end}/{total}", - "Content-Length": str(len(body))} - return None - - -async def shutdown_reader_range_flights(): - owners = getattr(app.state, "reader_range_flight_owners", set()) - await asyncio.gather(*(flight.close() for flight in list(owners)), return_exceptions=True) - owners.clear() - getattr(app.state, "reader_range_flights", {}).clear() - - -async def shared_reader_range_response(request, target_url, range_key, options): - if not hasattr(app.state, "reader_range_flights"): - app.state.reader_range_flights = {} - app.state.reader_range_flight_owners = set() - flights = app.state.reader_range_flights - owners = app.state.reader_range_flight_owners - policy = options["validation_scope"] if options["validation_scope"] is not None else options["url_validator"] - key = (range_key, policy, options["cache_control"]) - flight = flights.get(key) - joined = flight is not None - if flight is None: - if len(flights) >= READER_RANGE_MAX_FLIGHTS: - return await _stream_reader_document(request, target_url, _share_range=False, **options) - - def finished(owner): - if flights.get(key) is owner: - flights.pop(key, None) - if owner.done: - owners.discard(owner) - - flight = SharedRangeFlight( - lambda: _stream_reader_document(request, target_url, _share_range=False, **options), - READER_RANGE_CACHE_MAX_ITEM_BYTES, finished) - flights[key] = flight - owners.add(flight) - subscription = flight.subscribe() - shared_at = time.monotonic() - transferred = False - try: - if not await asyncio.shield(flight.ready): - response = flight.take_response() - if response is not None: - return response - return await _stream_reader_document(request, target_url, _share_range=False, **options) - headers = dict(flight.headers) - if joined: - headers["server-timing"] += f', reader_shared;dur={(time.monotonic() - shared_at) * 1000:.2f};desc="joined"' - response = OwnedStreamingResponse(subscription.stream(), cleanup=subscription.release, - status_code=206, headers=headers) - transferred = True - return response - finally: - if not transferred: - await subscription.release() +from backend.app import app diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2262b4a0489aa72a65adc2ef5f71ff96954031b2 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +"""Search service helpers; the FastAPI entry point remains app:app.""" diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000000000000000000000000000000000000..c19abef3ade74fc62af51c6f03ae9a3a9d731ded --- /dev/null +++ b/backend/app.py @@ -0,0 +1,2927 @@ +"""FastAPI application, routes and process-owned search state.""" + +import asyncio +import gzip +import hashlib +import json +import posixpath +import random +import re +import sys +import time +import functools +import threading +import weakref +from collections import OrderedDict +from array import array +from uuid import uuid4 +import io +import zipfile +import zlib +from pathlib import Path +from typing import Annotated, Literal, Optional, Sequence +from urllib.parse import unquote, quote, urlparse, parse_qs +import aiohttp +from huggingface_hub import HfFileSystem +import jieba +from fastapi import FastAPI, HTTPException, Query, Request +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, Response +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware +from pydantic import BaseModel, Field +from contextlib import asynccontextmanager +from backend.proxy_streams import OwnedStreamingResponse, SharedRangeFlight, UpstreamLease +from backend.search_execution import SearchBusy, SearchExecutor +from backend.search_indexes import ( + NGRAM_MAGIC, WORD_INDEX_MAGIC, WORD_INDEX_TOKENIZER, + decode_postings, decode_varint, load_ngram_index, load_word_index, + resolve_postings, +) +from backend.reader_assets import ( + READER_ASSET_FILENAMES, READER_BUCKET_PATH_RE, decode_reader_assets, +) +from backend.reader_v3_catalog import load_reading_catalog, merge_sidecar as merge_v3_sidecar +from backend.data_formats import ( + decode_browser_entry, decode_folder_browser_data, decode_folder_tree_data, + decode_search_payload, decode_tree_node, +) +from backend.record_metadata import ( + READER_ASSET_MODES, + add_reader_assets, build_browser_file_name, build_reader_asset_key, + build_record_link, build_record_path_url, build_record_relative_path, + build_txt_relative_path, enrich_browser_entry, enrich_reader_record, + reader_asset_url, reader_extension_for_asset, reader_resolved_record, +) +from backend.upstream_http import ( + ALLOWED_DOWNLOAD_HOSTS, ALLOWED_DOWNLOAD_HOST_SUFFIXES, MAX_REDIRECTS, + READER_ASSET_PATH_RE, READER_ASSET_SOURCE_RE, VOICEOFML_READER_SOURCE_RE, + decode_url_path, is_allowed_download_host, is_immutable_reader_asset, + normalize_download_url, open_download_response, source_url_scope, + validate_download_url, validate_path_safety, validate_reader_redirect_url, + validate_reader_source_url, validate_voiceofml_redirect_url, validate_voiceofml_source_url, +) +MAX_ARCHIVE_NAME_LENGTH = 240 +DATA_PATH = Path("data/search_data.json.gz") +NGRAM_PATHS = {2: Path("data/search_ngrams_2.bin.gz"), 3: Path("data/search_ngrams_3.bin.gz")} +WORD_INDEX_PATH = Path("data/search_words.bin.gz") +FOLDER_TREE_PATH = Path("data/folder_tree.json.gz") +FOLDER_BROWSER_PATH = Path("data/folder_browser.json.gz") +TXT_DIR = Path("txt") +TXT_SPACE_RAW_BASE = "https://huggingface.co/spaces/VoiceOfML/Search/raw/main/txt" +TXT_WARM_INTERVAL_SECONDS = 600 +TXT_WARM_IDLE_SECONDS = 600 +READER_ASSETS_URL = "https://huggingface.co/buckets/vomebook/reader-assets-v2/resolve/reader-index/reader_assets.json.gz" +READER_ASSETS_CACHE_TTL_SECONDS = 300 +READER_ASSETS_MAX_COMPRESSED_BYTES = 5 * 1024 * 1024 +READER_ASSETS_MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024 +READER_ASSET_ROUTING_CONTRACT_VERSION = "reader-buckets-v2" +records: list[dict] = [] +folder_tree_data: dict[str, list[dict]] = {} +folder_browser_data: dict[str, dict[str, dict]] = {} +word_index = {} +folder_index = {} +extension_counts = {} +repo_counts = {} +repo_list = [] +extension_list = [] +repo_extension_counts = {} +vocab_by_len = {} +repo_records_map = {} +sorted_by_name = [] +sorted_by_size = [] +repo_sorted_by_name = {} +repo_sorted_by_size = {} +txt_record_indices = [] +repo_txt_record_indices = {} +READER_EXTENSIONS = frozenset({"pdf", "djvu", "caj", "kdh", "epub", "mobi", "azw", "azw3", "fb2", "fbz", "html", "htm", "txt", "md", "markdown", "vcf", "ini", "jpg", "jpeg", "png", "gif", "bmp", "tif", "tiff", "webp", "mp3", "mp4", "wav", "m4a", "flac", "mov", "mpga", "wma", "ape", "amr"}) +reader_record_indices = [] +repo_reader_record_indices = {} +reader_record_key_indices = {} +reader_source_id_indices = {} +_reader_indexes_ready = False +_reader_indexes_lock = threading.Lock() +_reader_index_build = None +READER_INDEX_BATCH_RECORDS = 256 +READER_INDEX_BATCH_SECONDS = .008 +READER_INDEX_WARM_DELAY_SECONDS = 5 +READER_INDEX_IDLE_SECONDS = 5 +initial_payload_global = None +initial_payload_by_repo = {} +API_CACHE_TTL_SECONDS = 120 +RESPONSE_CACHE_BYTES_MAX = 16 * 1024 * 1024 +RESPONSE_CACHE_ENTRY_BYTES_MAX = 2 * 1024 * 1024 +_api_response_cache_lock = threading.Lock() +api_response_cache = {} +_data_generation = 0 +SEARCH_CACHE_TTL_SECONDS = 120 +_search_cache: dict[tuple, tuple[float, dict, int]] = {} +_search_instance = uuid4().hex +# Bump when matching, filtering, ordering or public record semantics change. +SEARCH_SEMANTICS_VERSION = "hf-search-v2" +_search_generation = None +_search_logged_generation = None +_search_order_cache = OrderedDict() +SEARCH_ORDER_CACHE_MAX = 32 +SEARCH_ORDER_CACHE_BYTES_MAX = 16 * 1024 * 1024 +SEARCH_ORDER_CACHE_TTL_SECONDS = 600 +_search_anchor_cache = OrderedDict() +SEARCH_ANCHOR_ORDER_MAX = 8 +SEARCH_ANCHOR_PER_ORDER_MAX = 8 +SEARCH_ANCHOR_KEY_MAX = 4096 +METADATA_CACHE_HEADERS = { + "Cache-Control": "public, max-age=300, stale-while-revalidate=1800", +} +ngram_postings: dict[int, dict[str, bytes]] = {} +ngram_posting_buffers: dict[int, bytes] = {} +injected_html_cache: dict[str | None, str] = {} +last_user_request_at = time.monotonic() +reader_assets_cache = {"v": 1, "f": {}} +reader_assets_cache_at = 0.0 +reader_assets_revision = hashlib.sha256(b'{"v":1,"f":{}}').hexdigest() +reader_join_cache_key = None +reader_join_cache = {"all": {}, "repos": {}} + +def _cached(store, key, builder, ttl, limit): + now = time.monotonic() + cached = store.get(key) + if cached and now - cached[0] < ttl: + store[key] = store.pop(key) + return cached[1] + value = builder() + store.pop(key, None) + for old_key, entry in list(store.items()): + if now - entry[0] >= ttl: + del store[old_key] + size = (len(value) if isinstance(value, bytes) else len(json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))) + len(str(key).encode("utf-8")) + if size > RESPONSE_CACHE_ENTRY_BYTES_MAX: + return value + total = sum(entry[2] if len(entry) > 2 else len(json.dumps(entry[1]).encode("utf-8")) for entry in store.values()) + while store and (len(store) >= limit or total + size > RESPONSE_CACHE_BYTES_MAX): + oldest_key = next(iter(store)) + entry = store.pop(oldest_key) + total -= entry[2] if len(entry) > 2 else len(json.dumps(entry[1]).encode("utf-8")) + if size <= RESPONSE_CACHE_BYTES_MAX: + store[key] = (now, value, size) + return value + + +def cached_payload(key, builder): + with _api_response_cache_lock: + return _cached(api_response_cache, key, builder, API_CACHE_TTL_SECONDS, 500) + + +def cached_json_response(key, builder, headers=None): + # Encode once, measure the actual body and reuse it within the existing budget. + body = cached_payload(("json", key), lambda: JSONResponse(builder()).body) + return Response(body, media_type="application/json", headers=headers) + + +async def get_reader_assets(*, wait: bool = True) -> dict: + if reader_assets_cache_at != 0 and time.monotonic() - reader_assets_cache_at < READER_ASSETS_CACHE_TTL_SECONDS: + return reader_assets_cache + task = getattr(app.state, "reader_assets_task", None) + if task is None or task.done(): + task = asyncio.create_task(refresh_reader_assets()) + app.state.reader_assets_task = task + # Search can enrich with the last snapshot while an owned refresh runs. + return await asyncio.shield(task) if wait else reader_assets_cache + + +async def refresh_reader_assets() -> dict: + global reader_assets_cache, reader_assets_cache_at, reader_assets_revision + now = time.monotonic() + if reader_assets_cache_at != 0 and now - reader_assets_cache_at < READER_ASSETS_CACHE_TTL_SECONDS: + return reader_assets_cache + async with app.state.reader_assets_lock: + now = time.monotonic() + if reader_assets_cache_at != 0 and now - reader_assets_cache_at < READER_ASSETS_CACHE_TTL_SECONDS: + return reader_assets_cache + response = None + try: + for url in (READER_ASSETS_URL,): + response = await app.state.http_session.get( + f"{url}?v={int(time.time())}", + timeout=aiohttp.ClientTimeout(total=20, connect=8, sock_read=10), + ) + if response.status == 200: + break + if response.status != 404: + raise RuntimeError(f"reader assets HTTP {response.status}") + response.release() + response = None + if response is None: + raise RuntimeError("reader assets bucket and legacy sources unavailable") + chunks = bytearray() + async for chunk in response.content.iter_chunked(64 * 1024): + chunks.extend(chunk) + if len(chunks) > READER_ASSETS_MAX_COMPRESSED_BYTES: + raise ValueError("reader assets response is too large") + raw = bytes(chunks) + decoded = await asyncio.to_thread(decode_reader_assets, raw) + v3_catalog = await load_reading_catalog(app.state.http_session) + if v3_catalog is None and reader_assets_cache.get("v3"): + raise ValueError("previously published v3 pointer disappeared") + decoded = merge_v3_sidecar(decoded, v3_catalog) + reader_assets_cache = decoded + revision_body = raw + json.dumps(decoded["v3"], sort_keys=True).encode() if v3_catalog else raw + reader_assets_revision = hashlib.sha256(revision_body).hexdigest() + reader_assets_cache_at = time.monotonic() + except Exception as exc: + print(f"reader assets refresh failed: {exc}") + # Retain last-known assets, but retry failures after a short backoff. + reader_assets_cache_at = time.monotonic() - READER_ASSETS_CACHE_TTL_SECONDS + 5 + finally: + if response is not None: + response.release() + return reader_assets_cache + +def records_digest() -> bytes: + digest = hashlib.sha256() + for record in records: + for value in ( + record.get("_file_lower", ""), + record.get("_repo_lower", ""), + record.get("_folder_path_lower", ""), + ): + digest.update(value.encode("utf-8")) + digest.update(b"\0") + return digest.digest() + + +def word_postings(token: str) -> set[int]: + value = word_index.get(token) + return set(value) if value is not None else set() + + +def word_posting_count(token: str) -> int: + value = word_index.get(token) + return len(value) if value is not None else 0 + +def literal_candidates(query: str, repos=None) -> set[int] | None: + width = 3 if len(query) >= 3 and ngram_postings.get(3) else 2 + postings = ngram_postings.get(width) + if not postings or len(query) < width: + return None + entries = [] + for gram in dict.fromkeys(query[index:index + width] for index in range(len(query) - width + 1)): + encoded = postings.get(gram) + if encoded is None: + return set() + entries.append(encoded) + # VNG3 entries carry validated counts; materialized test postings have lengths. + entries.sort(key=lambda entry: entry[2] if isinstance(entry, tuple) else len(entry)) + candidates = None + allowed = None + if repos: + allowed = set() + for repo in repos: + allowed.update(repo_records_map.get(repo, [])) + for encoded in entries: + matches = resolve_postings(encoded, ngram_posting_buffers.get(width, b"")) + if candidates is None: + candidates = matches + if allowed is not None: + candidates.intersection_update(allowed) + else: + candidates.intersection_update(matches) + if not candidates: + return set() + return candidates or set() +INITIAL_PAGE_SIZE = 100 +SIDEBAR_DATA_PATH = Path("data/sidebar") +RECORD_KEY_MAP = { + "r": "Repo", + "f": "File", + "e": "Extension", + "d": "Folder", + "s": "Size", + "t": "HasTxt", +} +TREE_KEY_MAP = { + "n": "name", + "c": "count", + "df": "hasDirectFiles", + "ch": "children", +} +BROWSER_ENTRY_KEY_MAP = { + "d": "folders", + "f": "files", +} +BROWSER_FOLDER_ITEM_KEY_MAP = { + "n": "name", + "c": "count", +} +BROWSER_FILE_ITEM_KEY_MAP = { + "n": "name", + "e": "ext", + "t": "hasTxt", + "s": "size", +} + +READER_SHORT_ID_RE = re.compile(r"^[0-9a-z]{13}$") +# Only lowercase escapes, encoded unreserved ASCII and malformed percent signs +# require normalization. Canonical reserved/UTF-8 escapes can pass through. +READER_ESCAPE_NORMALIZATION_RE = re.compile( + r"%(?:[0-9a-fA-F][a-f]|[a-f][0-9a-fA-F]|2[DE]|3[0-9]|4[1-9A-F]|5[0-9AF]|6[1-9A-F]|7[0-9AE])" + r"|%(?![0-9a-fA-F]{2})" +) + + +def canonical_reader_source_url(value: str) -> str: + # Match reader/core/reader-contract.js byte normalization without decoding path separators + # or treating an encoded percent sign as a second escape sequence. + def path_replace(match): + def token_replace(token_match): + token = token_match.group() + if len(token) == 3: + char = chr(int(token[1:], 16)) + return char if char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~-" else token.upper() + return "%25" + # Quote once, then skip already-canonical reserved and UTF-8 escapes. + path = quote(match[2], safe="/%") + return match[1] + READER_ESCAPE_NORMALIZATION_RE.sub(token_replace, path) + return re.sub(r"^(https://huggingface\.co/datasets/VoiceOfML/)([^?#]*)", path_replace, value) + + +def reader_short_id(value: str) -> str: + digest = 1469598103934665603 + for byte in canonical_reader_source_url(value).encode("utf-8"): + digest = ((digest ^ byte) * 1099511628211) & ((1 << 64) - 1) + return _base36(digest).rjust(13, "0") + + +def _base36(value: int) -> str: + alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" + output = "" + while value: + value, remainder = divmod(value, 36) + output = alphabet[remainder] + output + return output or "0" + + +def sanitize_archive_name(value) -> str: + name = str(value or "file").replace("\\", "/").rsplit("/", 1)[-1] + name = "".join(ch for ch in name if ord(ch) >= 32 and ch != "\x7f").strip() + if not name or name in (".", ".."): + name = "file" + return name[:MAX_ARCHIVE_NAME_LENGTH] + +def tokenize(text: str) -> list[str]: + tokens = [] + text_lower = text.lower() + alpha_tokens = re.findall(r"[a-z0-9]+", text_lower) + tokens.extend(alpha_tokens) + chinese_text = re.sub(r"[a-z0-9\s]+", " ", text_lower) + chinese_text = re.sub(r"[^\u4e00-\u9fff\u3400-\u4dbf\s]+", " ", chinese_text) + chinese_tokens = jieba.lcut(chinese_text) + tokens.extend([t.strip() for t in chinese_tokens if t.strip()]) + chinese_token_set = set(chinese_tokens) + for ch in text_lower: + if "\u4e00" <= ch <= "\u9fff" or "\u3400" <= ch <= "\u4dbf": + if ch not in chinese_token_set: + tokens.append(ch) + return list(set(tokens)) + +def compile_exact_matcher(query: str): + if "*" not in query and "?" not in query: + lower = query.lower() + return lambda text: lower in text.lower() + # Bitset NFA: each bit is a pattern position. Collapse adjacent stars so + # epsilon closure needs one shift. No input can cause regex backtracking. + pattern = re.sub(r"\*+", "*", query) + stars = questions = 0 + literals = {} + for index, char in enumerate(pattern): + bit = 1 << index + if char == "*": + stars |= bit + elif char == "?": + questions |= bit + else: + literals[char] = literals.get(char, 0) | bit + # Single-character regexes retain Python IGNORECASE's Unicode semantics. + literals = [(re.compile(re.escape(char), re.IGNORECASE), bits) + for char, bits in literals.items()] + masks = {} + end = 1 << len(pattern) + start = 1 | ((1 & stars) << 1) + + def matches(text): + # Every literal is mandatory; C-level scans reject unrelated fields early. + if any(regex.search(text) is None for regex, _ in literals): + return False + active = start + if active & end: + return True + for char in text: + mask = masks.get(char) + if mask is None: + mask = sum(bits for regex, bits in literals if regex.fullmatch(char)) + if len(masks) >= 512: + masks.clear() + masks[char] = mask + wildcard = char != "\n" + active = ((active & (mask | (questions if wildcard else 0))) << 1) | ( + active & stars if wildcard else 0) | 1 + active |= (active & stars) << 1 + if active & end: + return True + return False + return matches + + +def matches_exact_query(text: str, query: str) -> bool: + return compile_exact_matcher(query)(text) + +def edit_distance(s1: str, s2: str, max_dist: int = 2) -> int: + if abs(len(s1) - len(s2)) > max_dist: + return 999 + prev = list(range(len(s2) + 1)) + for i, c1 in enumerate(s1): + curr = [i + 1] + for j, c2 in enumerate(s2): + cost = 0 if c1 == c2 else 1 + curr.append(min(prev[j + 1] + 1, curr[j] + 1, prev[j] + cost)) + if min(curr) > max_dist: + return 999 + prev = curr + return prev[-1] + +def could_be_fuzzy(tok: str, word: str, max_dist: int = 2) -> bool: + if abs(len(tok) - len(word)) > max_dist: + return False + if len(tok) < 4: + return True + counts: dict[str, int] = {} + for ch in tok: + counts[ch] = counts.get(ch, 0) + 1 + diff = 0 + for ch in word: + if counts.get(ch, 0) > 0: + counts[ch] -= 1 + else: + diff += 1 + if diff > 2 * max_dist: + return False + for remaining in counts.values(): + diff += remaining + if diff > 2 * max_dist: + return False + return True + + +def is_fuzzy_match(token: str, word: str) -> bool: + return could_be_fuzzy(token, word, 2) and edit_distance(token, word, max_dist=2) <= 2 + + +def matches_file_token(record: dict, token: str) -> bool: + return token in record.get("_file_lower", "") or token in record.get("_repo_lower", "") + + +def scoped_word_postings(token: str, search_folders: bool): + postings = word_postings(token) + if search_folders: + return postings + return {index for index in postings if matches_file_token(records[index], token)} + + +def matches_file_query(record: dict, tokens, fuzzy_tokens) -> bool: + file_tokens = tokenize(record.get("_file_lower", "")) if fuzzy_tokens else [] + return all(matches_file_token(record, token) or ( + token in fuzzy_tokens and any(is_fuzzy_match(token, word) for word in file_tokens) + ) for token in tokens) + + +def build_word_index(): + global word_index, vocab_by_len + word_index = {} + for idx, rec in enumerate(records): + file_name = rec.get("File", "") + folders = rec.get("Folder", []) + for tok in tokenize(" ".join([file_name] + folders)): + word_index.setdefault(tok, set()).add(idx) + vocab_by_len = {} + for word in word_index: + vocab_by_len.setdefault(len(word), []).append(word) + + +def build_vocab_by_len(): + global vocab_by_len + vocab_by_len = {} + for word in word_index: + vocab_by_len.setdefault(len(word), []).append(word) + + +def _advance_reader_indexes(limit=READER_INDEX_BATCH_RECORDS, budget=READER_INDEX_BATCH_SECONDS): + global reader_record_indices, repo_reader_record_indices + global reader_record_key_indices, reader_source_id_indices, _reader_indexes_ready + global _reader_index_build + with _reader_indexes_lock: + if _reader_indexes_ready: + return True + if _reader_index_build is None or _reader_index_build["generation"] != _data_generation: + _reader_index_build = {"generation": _data_generation, "records": records, "next": 0, + "indices": [], "repos": {}, "keys": {}, "ids": {}} + build = _reader_index_build + deadline = time.perf_counter() + budget + end = min(len(build["records"]), build["next"] + limit) + while build["next"] < end: + idx = build["next"] + rec = build["records"][idx] + repo = rec.get("Repo", "") + build["keys"][build_reader_asset_key(rec)] = idx + source_id = reader_short_id(rec.get("Link") or build_record_link(rec)) + build["ids"].setdefault(source_id, []).append(idx) + if rec.get("_ext_lower", "") in READER_EXTENSIONS: + build["indices"].append(idx) + build["repos"].setdefault(repo, []).append(idx) + build["next"] += 1 + if time.perf_counter() >= deadline: + break + if build["next"] < len(build["records"]): + return False + if build["generation"] != _data_generation or build["records"] is not records: + _reader_index_build = None + return False + reader_record_indices = array('I', build["indices"]) + repo_reader_record_indices = {key: array('I', indices) for key, indices in build["repos"].items()} + reader_record_key_indices = build["keys"] + reader_source_id_indices = build["ids"] + _reader_indexes_ready = True + _reader_index_build = None + return True + + +def _build_reader_indexes(): + while not _advance_reader_indexes(len(records), float("inf")): + pass + + +def ensure_reader_indexes(): + global _reader_indexes_ready + if _reader_indexes_ready: + return + _build_reader_indexes() + + +async def ensure_reader_indexes_async(): + """Join the shared background build, or start one for an early Reader request.""" + if _reader_indexes_ready: + return + task = getattr(app.state, "reader_index_build_task", None) + if task is None or task.done(): + async def build(): + while not _reader_indexes_ready: + executor = getattr(app.state, "search_executor", None) + if executor is None: + await asyncio.to_thread(_advance_reader_indexes) + else: + try: + await executor.run(_advance_reader_indexes, background=True) + except SearchBusy: + await asyncio.sleep(.02) + continue + await asyncio.sleep(0) + task = asyncio.create_task(build()) + app.state.reader_index_build_task = task + await asyncio.shield(task) + + +async def warm_reader_indexes(): + """Prepare Reader routing during a quiet period without delaying startup.""" + await asyncio.sleep(READER_INDEX_WARM_DELAY_SECONDS) + while not _reader_indexes_ready: + while time.monotonic() - last_user_request_at < READER_INDEX_IDLE_SECONDS: + await asyncio.sleep(READER_INDEX_IDLE_SECONDS) + try: + await ensure_reader_indexes_async() + except SearchBusy: + await asyncio.sleep(READER_INDEX_IDLE_SECONDS) + + +def build_indexes(build_words=True, build_reader=True): + global _reader_index_build + global reader_source_id_indices, _search_generation + global _data_generation, word_index, folder_index, extension_counts, repo_counts, repo_list, extension_list, repo_extension_counts, repo_records_map, sorted_by_name, sorted_by_size, repo_sorted_by_name, repo_sorted_by_size, txt_record_indices, repo_txt_record_indices, reader_record_indices, repo_reader_record_indices, reader_record_key_indices, _reader_indexes_ready + _data_generation += 1 + _search_generation = None + _search_order_cache.clear() + _search_anchor_cache.clear() + _search_cache.clear() + word_index = {} + folder_index = {} + extension_counts = {} + repo_extension_counts = {} + repo_counts = {} + repo_records_map = {} + repo_sorted_by_name = {} + repo_sorted_by_size = {} + txt_record_indices = [] + repo_txt_record_indices = {} + reader_record_indices = [] + repo_reader_record_indices = {} + reader_record_key_indices = {} + reader_source_id_indices = {} + _reader_indexes_ready = False + _reader_index_build = None + for idx, rec in enumerate(records): + repo = rec.get("Repo", "") + repo_counts[repo] = repo_counts.get(repo, 0) + 1 + ext = (rec.get("Extension") or "").lower() + if ext: + extension_counts[ext] = extension_counts.get(ext, 0) + 1 + repo_ext = repo_extension_counts.setdefault(repo, {}) + repo_ext[ext] = repo_ext.get(ext, 0) + 1 + file_name = rec.get("File", "") + folders = rec.get("Folder", []) + if repo not in folder_index: + folder_index[repo] = {} + for depth in range(len(folders) + 1): + folder_path = "/".join(folders[:depth]) if depth > 0 else "" + folder_index[repo].setdefault(folder_path, []).append(idx) + repo_records_map.setdefault(repo, []).append(idx) + if rec.get("HasTxt"): + txt_record_indices.append(idx) + repo_txt_record_indices.setdefault(repo, []).append(idx) + folders_lower = [f.lower() for f in (folders or [])] + rec["_file_lower"] = file_name.lower() + rec["_folders_lower"] = folders_lower + rec["_folder_text"] = " ".join(folders_lower) + rec["_folder_path_lower"] = "/".join(folders_lower) + rec["_folder_path"] = "/".join(folders) + rec["_ext_lower"] = ext + rec["_repo_lower"] = repo.lower() + repo_list = sorted( + [{"name": r, "count": c} for r, c in repo_counts.items()], + key=lambda x: x["name"], + ) + if build_words: + build_word_index() + else: + word_index = {} + global vocab_by_len + vocab_by_len = {} + extension_list = sorted(extension_counts.keys()) + all_indices = list(range(len(records))) + sorted_by_name = sorted(all_indices, key=lambda i: (records[i].get("File") or "").lower()) + sorted_by_size = sorted(all_indices, key=lambda i: ( + -(records[i].get("Size") if isinstance(records[i].get("Size"), (int, float)) else 0), + (records[i].get("File") or "").lower(), + )) + for repo, repo_indices in repo_records_map.items(): + repo_sorted_by_name[repo] = sorted(repo_indices, key=lambda i: (records[i].get("File") or "").lower()) + repo_sorted_by_size[repo] = sorted(repo_indices, key=lambda i: ( + -(records[i].get("Size") if isinstance(records[i].get("Size"), (int, float)) else 0), + (records[i].get("File") or "").lower(), + )) + # These complete, ordered postings are read-only after publication. + # Packed IDs avoid retaining a Python pointer (and often an int) per slot. + sorted_by_name = array('I', sorted_by_name) + sorted_by_size = array('I', sorted_by_size) + txt_record_indices = array('I', txt_record_indices) + for mapping in (repo_records_map, repo_sorted_by_name, repo_sorted_by_size, + repo_txt_record_indices, *folder_index.values()): + for key, indices in mapping.items(): + mapping[key] = array('I', indices) + if build_reader: + _build_reader_indexes() + +def search_data_generation(raw: bytes, index_paths) -> str: + digest = hashlib.sha256() + digest.update(SEARCH_SEMANTICS_VERSION.encode()) + digest.update(str(sys.version_info[:3]).encode()) + digest.update(jieba.__version__.encode()) + digest.update(hashlib.sha256(raw).digest()) + for path in index_paths: + # Index content matters too; file timestamps and process IDs do not. + digest.update(path.name.encode()) + digest.update(hashlib.sha256(path.read_bytes()).digest() if path.exists() else b"missing") + return "api:" + digest.hexdigest() + + +def load_data(): + global records, folder_tree_data, folder_browser_data, ngram_postings, ngram_posting_buffers, word_index, _search_generation + start = time.perf_counter() + stage_start = start + timings = {} + if DATA_PATH.exists(): + raw = gzip.decompress(DATA_PATH.read_bytes()) + records = decode_search_payload(json.loads(raw.decode("utf-8"))) + else: + print("⚠ 数据文件不存在") + records = [] + return + if FOLDER_TREE_PATH.exists(): + folder_tree_data = decode_folder_tree_data(json.loads(gzip.decompress(FOLDER_TREE_PATH.read_bytes()).decode("utf-8"))) + else: + folder_tree_data = {} + if FOLDER_BROWSER_PATH.exists(): + folder_browser_data = decode_folder_browser_data(json.loads(gzip.decompress(FOLDER_BROWSER_PATH.read_bytes()).decode("utf-8"))) + else: + folder_browser_data = {} + timings["data_ms"] = round((time.perf_counter() - stage_start) * 1000, 2) + print(f"📖 已加载 {len(records)} 条记录 ({time.perf_counter() - start:.2f}s)") + stage_start = time.perf_counter() + # Reader ID/pool indexes are built on first Reader use; normal search does + # not need to scan every record to prepare them during cold startup. + build_indexes(build_words=False, build_reader=False) + digest = records_digest() + timings["metadata_ms"] = round((time.perf_counter() - stage_start) * 1000, 2) + stage_start = time.perf_counter() + word_index = load_word_index(WORD_INDEX_PATH, len(records), digest) + word_index_source = "generated" if word_index else "rebuilt" + if word_index: + build_vocab_by_len() + else: + build_word_index() + timings["words_ms"] = round((time.perf_counter() - stage_start) * 1000, 2) + stage_start = time.perf_counter() + loaded_ngrams = { + width: load_ngram_index(path, width, len(records), digest) + for width, path in NGRAM_PATHS.items() + } + ngram_postings = {width: loaded[0] for width, loaded in loaded_ngrams.items()} + ngram_posting_buffers = {width: loaded[1] for width, loaded in loaded_ngrams.items()} + timings["ngrams_ms"] = round((time.perf_counter() - stage_start) * 1000, 2) + stage_start = time.perf_counter() + _search_generation = search_data_generation(raw, [WORD_INDEX_PATH, *NGRAM_PATHS.values()]) + timings["fingerprint_ms"] = round((time.perf_counter() - stage_start) * 1000, 2) + print(f"🧭 已加载词索引: {len(word_index)}") + print(f"🔎 已加载 ngram 索引: 2={len(ngram_postings[2])}, 3={len(ngram_postings[3])}") + build_initial_payloads() + precompute_injected_html() + del raw + stage_start = time.perf_counter() + # A generated postings file avoids corpus tokenization, but query tokenization + # still needs jieba's dictionary. Prepare it before accepting the first query, + # after releasing the decoded source and finishing index-loading temporaries. + jieba.initialize() + timings["tokenizer_ms"] = round((time.perf_counter() - stage_start) * 1000, 2) + timings["total_ms"] = round((time.perf_counter() - start) * 1000, 2) + print("search_performance " + json.dumps({"stage": "startup", "word_index": word_index_source, **timings})) + +def score_record(rec_idx, query_tokens, search_folders=True): + rec = records[rec_idx] + score = 0 + file_name = rec.get("_file_lower", "") + repo = rec.get("_repo_lower", "") + folder_text = rec.get("_folder_text", "") + for tok in query_tokens: + if tok in file_name: + score += 3 + if search_folders and tok in folder_text: + score += 2 + if tok in repo: + score += 1 + return score + +def apply_filters(indices, repos=None, extensions=None, folders=None, min_size=None, max_size=None, folder_match_mode="prefix"): + repos_set = set(repos) if repos else None + extensions_lower = {e.lower() for e in extensions} if extensions else None + folder_set = {f.strip("/") for f in folders} if folders else None + if folder_match_mode != "exact" and folder_set and "" in folder_set: + # Unlike the Worker, an empty API prefix includes the complete scope. + folder_set = None + folder_prefix = next(iter(folder_set)) + "/" if folder_set and len(folder_set) == 1 and folder_match_mode != "exact" else None + result = [] + for idx in indices: + rec = records[idx] + if repos_set and rec.get("Repo") not in repos_set: + continue + if extensions_lower: + if rec.get("_ext_lower", "") not in extensions_lower: + continue + if folder_set: + rec_path = rec["_folder_path"] if "_folder_path" in rec else "/".join(rec.get("Folder", []) or []) + matched = rec_path in folder_set + if not matched and folder_match_mode != "exact": + if folder_prefix is not None: + matched = rec_path.startswith(folder_prefix) + else: + end = rec_path.rfind("/") + while end > 0: + if rec_path[:end] in folder_set: + matched = True + break + end = rec_path.rfind("/", 0, end) + if not matched: + continue + size = rec.get("Size") + if size and isinstance(size, (int, float)) and size > 0: + if min_size is not None and size < min_size: + continue + if max_size is not None and size > max_size: + continue + result.append(idx) + return result + +def public_record(record: dict) -> dict: + item = { + key: value + for key, value in record.items() + if isinstance(key, str) and not key.startswith("_") + } + item.setdefault("Repo", "") + item.setdefault("File", "") + item.setdefault("Extension", "") + item.setdefault("Folder", []) + item.setdefault("Size", "") + item.setdefault("HasTxt", False) + item["Link"] = record.get("Link") or build_record_link(record) + item["Path"] = record.get("Path") or build_record_path_url(record) + return item + + +def trim_results(raw): + keys = ("Repo", "File", "Extension", "Link", "Path", "Folder", "Size", "HasTxt") + return [ + {key: public.get(key, "" if key != "Folder" else []) for key in keys} + for public in (public_record(record) for record in raw) + ] + +def sort_key_name(idx: int): + return (records[idx].get("File") or "").lower() + +def sort_key_size(idx: int): + return ( + -(records[idx].get("Size") if isinstance(records[idx].get("Size"), (int, float)) else 0), + (records[idx].get("File") or "").lower(), + ) + +def paginate_indices(indices: Sequence[int], page: int, page_size: int) -> tuple[int, list[dict]]: + total = len(indices) + start = (page - 1) * page_size + return total, [records[i] for i in indices[start:start + page_size]] + +def get_initial_indices(repos=None) -> Sequence[int]: + if repos: + indices = [] + for repo in dict.fromkeys(repos): + indices.extend(repo_records_map.get(repo, [])) + return indices + return range(len(records)) + +def search_generation() -> str: + return _search_generation or f"api:{_search_instance}:{_data_generation}" + + +def build_response(results: list[dict], total: int, page: int, page_size: int) -> dict: + return { + "results": trim_results(results), + "total": total, + "page": page, + "page_size": page_size, + "generation": search_generation(), + } + + +def trim_initial_results(raw: list[dict]) -> list[dict]: + keys = ("Repo", "File", "Extension", "Folder", "Size", "HasTxt") + return [ + {key: record.get(key, [] if key == "Folder" else "") for key in keys} + for record in raw + ] + +def build_initial_payload(repo: str | None = None) -> dict: + if repo: + indices = repo_records_map.get(repo, []) + result_records = [records[i] for i in indices[:INITIAL_PAGE_SIZE]] + mode = "repo" + total = len(indices) + else: + result_records = records[:INITIAL_PAGE_SIZE] + mode = "global" + total = len(records) + return { + "mode": mode, + "repo": repo, + "sort": "relevance", + "page": 1, + "page_size": INITIAL_PAGE_SIZE, + "total": total, + "results": trim_initial_results(result_records), + "generation": search_generation(), + } + +def build_initial_payloads(): + global initial_payload_global, initial_payload_by_repo + initial_payload_global = build_initial_payload(None) + initial_payload_by_repo = { + repo: build_initial_payload(repo) + for repo in repo_records_map + } + +def precompute_injected_html(): + global injected_html_cache + index_path = Path("static/index.html") + if not index_path.exists(): + injected_html_cache = {} + return + html = index_path.read_text(encoding="utf-8") + injected_html_cache = {None: inject_initial_payload(html, None)} + for repo in repo_records_map: + injected_html_cache[repo] = inject_initial_payload(html, repo.split("/")[-1]) + +def load_sidebar_payload(path: Path): + if not path.exists() or not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + +def build_global_bootstrap(): + sidebar = load_sidebar_payload(SIDEBAR_DATA_PATH / "global.json") or {} + return { + "generation": _data_generation, + "repos": repo_list, + "extensions": [{"name": name, "count": extension_counts[name]} for name in sorted(extension_counts)], + "random_txt": {"available": bool(txt_record_indices), "count": len(txt_record_indices)}, + "sidebar": sidebar, + } + +def inject_initial_payload(html: str, repo_short: str | None = None) -> str: + repo = f"VoiceOfML/{repo_short}" if repo_short else None + payload = initial_payload_by_repo.get(repo) if repo else initial_payload_global + if not payload: + return html + script = ( + '" + ) + app_script = re.search(r'', html) + if app_script: + return html[:app_script.start()] + script + "\n" + app_script.group(0) + html[app_script.end():] + return html.replace("", script + "\n") + +def apply_mixed_folder_filters(indices, self_folders=None, subtree_folders=None): + self_set = {str(path).strip("/") for path in (self_folders or [])} + subtree_set = {str(path).strip("/") for path in (subtree_folders or []) if str(path).strip("/")} + if not self_set and not subtree_set: + return indices + result = [] + for idx in indices: + rec = records[idx] + rec_folders = rec.get("Folder", []) or [] + rec_path = "/".join(rec_folders) + if rec_path in self_set: + result.append(idx) + continue + matched = False + for depth in range(1, len(rec_folders) + 1): + prefix = "/".join(rec_folders[:depth]) + if prefix in subtree_set: + matched = True + break + if matched: + result.append(idx) + return result + +def search(q="", repos=None, extensions=None, folders=None, min_size=None, max_size=None, page=1, page_size=100, search_folders=True, sort="relevance", exact=False, folder_match_mode="prefix", folder_selfs=None, folder_subtrees=None, anchor_id=None): + global _search_logged_generation + started = time.perf_counter() + q = q.strip() + order_key = _search_order_key(q, repos, extensions, folders, min_size, max_size, + search_folders, sort, exact, folder_match_mode, + folder_selfs, folder_subtrees) + key = order_key + (page, page_size, anchor_id) + cached_page = _search_cache.get(key) + cache_hit = bool(cached_page and time.monotonic() - cached_page[0] < SEARCH_CACHE_TTL_SECONDS) + result = _cached(_search_cache, key, + lambda: _search_uncached(q, repos, extensions, folders, + min_size, max_size, page, page_size, + search_folders, sort, exact, + folder_match_mode, folder_selfs, + folder_subtrees, anchor_id), + SEARCH_CACHE_TTL_SECONDS, 500) + cached_order = _search_order_cache.get(order_key) + if cached_order is not None and time.monotonic() - cached_order[0] < SEARCH_ORDER_CACHE_TTL_SECONDS: + _search_order_cache[order_key] = (time.monotonic(), cached_order[1]) + _search_order_cache.move_to_end(order_key) + duration_ms = (time.perf_counter() - started) * 1000 + first = _search_logged_generation != _data_generation + if first or duration_ms >= 250: + _search_logged_generation = _data_generation + # Operational timing only; never log queries, paths, anchors or URLs. + print("search_performance " + json.dumps({"stage": "search", "first": first, + "duration_ms": round(duration_ms, 2), "page_cache_hit": cache_hit})) + return result + + +def _search_order_key(q, repos, extensions, folders, min_size, max_size, search_folders, + sort, exact, folder_match_mode, folder_selfs, folder_subtrees): + return (_data_generation, q.strip(), tuple(repos or ()), tuple(extensions or ()), + tuple(folders or ()), min_size, max_size, search_folders, sort, exact, + folder_match_mode, tuple(folder_selfs or ()), tuple(folder_subtrees or ())) + + +def _get_search_order(q, repos, extensions, folders, min_size, max_size, search_folders, + sort, exact, folder_match_mode, folder_selfs, folder_subtrees): + key = _search_order_key(q, repos, extensions, folders, min_size, max_size, + search_folders, sort, exact, folder_match_mode, + folder_selfs, folder_subtrees) + now = time.monotonic() + for old_key, (created, _) in list(_search_order_cache.items()): + if now - created >= SEARCH_ORDER_CACHE_TTL_SECONDS: + del _search_order_cache[old_key] + cached = _search_order_cache.get(key) + if cached is not None: + _search_order_cache[key] = (now, cached[1]) + _search_order_cache.move_to_end(key) + return cached[1] + indices = _search_order_uncached(q, repos, extensions, folders, min_size, max_size, + search_folders, sort, exact, folder_match_mode, + folder_selfs, folder_subtrees) + # Oversized queries still return all results; only caching is skipped. + packed = indices if isinstance(indices, array) else array('I', indices) + size = len(packed) * packed.itemsize + if size <= SEARCH_ORDER_CACHE_BYTES_MAX: + while _search_order_cache and (len(_search_order_cache) >= SEARCH_ORDER_CACHE_MAX or + sum(len(v[1]) * v[1].itemsize for v in _search_order_cache.values()) + size > SEARCH_ORDER_CACHE_BYTES_MAX): + _search_order_cache.popitem(last=False) + _search_order_cache[key] = (now, packed) + return packed + + +def _find_search_anchor(indices, anchor_id): + owner = id(indices) + cached = _search_anchor_cache.get(owner) + if cached is not None and cached[0]() is not indices: + del _search_anchor_cache[owner] + cached = None + if cached is not None and anchor_id in cached[1]: + cached[1].move_to_end(anchor_id) + _search_anchor_cache.move_to_end(owner) + return cached[1][anchor_id] + anchor_repo, separator, anchor_path = anchor_id.partition('\0') + index = -1 + if separator: + for offset, idx in enumerate(indices): + rec = records[idx] + repo = str(rec.get('Repo', '')) + if repo == anchor_repo: + matches = build_record_relative_path(rec) == anchor_path + elif '\0' in repo and anchor_id.startswith(f'{repo}\0'): + # Preserve raw-key matching for injected repository names. + matches = f'{repo}\0{build_record_relative_path(rec)}' == anchor_id + else: + continue + if matches: + index = offset + break + if len(anchor_id) <= SEARCH_ANCHOR_KEY_MAX: + lookups = cached[1] if cached is not None else OrderedDict() + lookups[anchor_id] = index + while len(lookups) > SEARCH_ANCHOR_PER_ORDER_MAX: + lookups.popitem(last=False) + # Do not keep a complete order alive after its normal cache eviction. + _search_anchor_cache[owner] = (weakref.ref(indices), lookups) + _search_anchor_cache.move_to_end(owner) + while len(_search_anchor_cache) > SEARCH_ANCHOR_ORDER_MAX: + _search_anchor_cache.popitem(last=False) + return index + + +def _search_uncached(q="", repos=None, extensions=None, folders=None, min_size=None, max_size=None, page=1, page_size=100, search_folders=True, sort="relevance", exact=False, folder_match_mode="prefix", folder_selfs=None, folder_subtrees=None, anchor_id=None): + indices = _get_search_order(q, repos, extensions, folders, min_size, max_size, + search_folders, sort, exact, folder_match_mode, + folder_selfs, folder_subtrees) + total, results = paginate_indices(indices, page, page_size) + response = build_response(results, total, page, page_size) + if anchor_id is not None: + response['anchor_index'] = _find_search_anchor(indices, anchor_id) + return response + + +def _search_order_uncached(q="", repos=None, extensions=None, folders=None, min_size=None, max_size=None, search_folders=True, sort="relevance", exact=False, folder_match_mode="prefix", folder_selfs=None, folder_subtrees=None): + q = q.strip() + + if exact and q: + matched_indices = [] + _q_lower = q.lower() + _is_match = compile_exact_matcher(q) + candidate_indices = get_initial_indices(repos) + if set(q) <= {"*"}: + # A star-only pattern matches every string, including an empty field. + matched_indices = list(candidate_indices) + elif "*" not in q and "?" not in q: + candidates = literal_candidates(_q_lower, repos) + if candidates is not None: + candidate_indices = sorted(candidates) + if not matched_indices: + for idx in candidate_indices: + rec = records[idx] + file_name = rec.get("_file_lower", "") + folder_path = rec.get("_folder_path_lower", "") + repo_name = rec.get("_repo_lower", "") + if _is_match(file_name) or _is_match(repo_name) or (search_folders and _is_match(folder_path)): + matched_indices.append(idx) + if folder_match_mode == "mixed": + filtered = apply_mixed_folder_filters(matched_indices, folder_selfs, folder_subtrees) + filtered = apply_filters(filtered, None, extensions, None, min_size, max_size) + else: + filtered = apply_filters(matched_indices, None, extensions, folders, min_size, max_size, folder_match_mode) + if sort == "name": + filtered.sort(key=sort_key_name) + elif sort == "size": + filtered.sort(key=sort_key_size) + return filtered + if not q: + has_record_filters = extensions or folders or min_size is not None or max_size is not None or folder_match_mode == "mixed" + can_use_precomputed = not has_record_filters and (not repos or len(repos) == 1) + if can_use_precomputed: + if repos and len(repos) == 1: + repo = repos[0] + if sort == "name": + indices = repo_sorted_by_name.get(repo, []) + elif sort == "size": + indices = repo_sorted_by_size.get(repo, []) + else: + indices = repo_records_map.get(repo, []) + else: + if sort == "name": + indices = sorted_by_name + elif sort == "size": + indices = sorted_by_size + else: + indices = range(len(records)) + return indices + else: + indices = get_initial_indices(repos) + if folder_match_mode == "mixed": + filtered = apply_mixed_folder_filters(indices, folder_selfs, folder_subtrees) + filtered = apply_filters(filtered, repos, extensions, None, min_size, max_size) + else: + filtered = apply_filters(indices, repos, extensions, folders, min_size, max_size, folder_match_mode) + if sort == "name": + filtered.sort(key=sort_key_name) + elif sort == "size": + filtered.sort(key=sort_key_size) + return filtered + query_tokens = tokenize(q) + fuzzy_candidate_limit = 200 if len(query_tokens) == 1 else None + + posting_cache = {} + def query_postings(token): + if token not in posting_cache: + posting_cache[token] = scoped_word_postings(token, search_folders=search_folders) + return posting_cache[token] + + exact_constraint = None + for tok in query_tokens: + if tok not in word_index: + continue + postings = query_postings(tok) + if not postings: + continue + exact_constraint = set(postings) if exact_constraint is None else exact_constraint.intersection(postings) + token_candidates = [] + fuzzy_tokens = set() + for tok in query_tokens: + candidates = query_postings(tok) if tok in word_index else set() + if not candidates: + fuzzy_tokens.add(tok) + fuzzy_candidates = set() + if exact_constraint is not None: + for idx in sorted(exact_constraint): + rec = records[idx] + record_tokens = tokenize(" ".join([rec.get("File", ""), + *((rec.get("Folder", []) or []) if search_folders else [])])) + if any(is_fuzzy_match(tok, word) for word in record_tokens): + fuzzy_candidates.add(idx) + if fuzzy_candidate_limit is not None and len(fuzzy_candidates) >= fuzzy_candidate_limit: + break + else: + tok_len = len(tok) + for delta in (-2, -1, 0, 1, 2): + for vocab_word in vocab_by_len.get(tok_len + delta, []): + if is_fuzzy_match(tok, vocab_word): + fuzzy_candidates.update(query_postings(vocab_word)) + if fuzzy_candidate_limit is not None and len(fuzzy_candidates) >= fuzzy_candidate_limit: + break + if fuzzy_candidate_limit is not None and len(fuzzy_candidates) >= fuzzy_candidate_limit: + break + candidates = fuzzy_candidates + if not candidates: + token_candidates = [] + break + token_candidates.append(candidates) + if token_candidates: + token_candidates.sort(key=len) + matched = set(token_candidates[0]) + for candidates in token_candidates[1:]: + matched.intersection_update(candidates) + if not matched: + break + all_matched = list(matched) + else: + all_matched = [] + literal_query = q.lower() + if literal_query: + literal_matches = [] + initial_indices = get_initial_indices(repos) + candidates = literal_candidates(literal_query, repos) + if candidates is not None: + initial_indices = sorted(candidates) + skip = set(all_matched) + for idx in initial_indices: + if skip and idx in skip: + continue + rec = records[idx] + file_name = rec.get("_file_lower", "") + folder_path = rec.get("_folder_path_lower", "") + repo_name = rec.get("_repo_lower", "") + if literal_query in file_name or literal_query in repo_name or (search_folders and literal_query in folder_path): + literal_matches.append(idx) + if literal_matches: + all_matched.extend(idx for idx in literal_matches if idx not in skip) + if not search_folders and query_tokens and all_matched: + all_matched = [idx for idx in all_matched + if matches_file_query(records[idx], query_tokens, fuzzy_tokens)] + if folder_match_mode == "mixed": + filtered = apply_mixed_folder_filters(all_matched, folder_selfs, folder_subtrees) + filtered = apply_filters(filtered, repos, extensions, None, min_size, max_size) + else: + filtered = apply_filters(all_matched, repos, extensions, folders, min_size, max_size, folder_match_mode) + if sort == "name": + filtered.sort(key=sort_key_name) + return filtered + if sort == "size": + filtered.sort(key=sort_key_size) + return filtered + scored = [(idx, score_record(idx, query_tokens, search_folders)) for idx in filtered] + if sort == "relevance": + scored.sort(key=lambda x: (-x[1], x[0])) + return [idx for idx, _ in scored] + +def build_filter_folder_tree(repo: str) -> list[dict]: + if repo in folder_tree_data: + return folder_tree_data[repo] + if repo not in folder_index: + return [] + repo_name_short = repo.split("/")[-1] + tree_root = { + "name": repo_name_short, + "path": "", + "children": [], + "count": 0, + "hasDirectFiles": False, + "hasChildren": False, + "showSelfToggle": False, + "isRoot": True, + } + node_map = {"": tree_root} + for path, idx_list in folder_index[repo].items(): + if path == "": + tree_root["count"] = len(idx_list) + continue + parts = path.split("/") + for depth in range(1, len(parts) + 1): + key = "/".join(parts[:depth]) + if key not in node_map: + parent_key = "/".join(parts[:depth - 1]) if depth > 1 else "" + parent = node_map[parent_key] + node = { + "name": parts[depth - 1], + "path": key, + "children": [], + "count": 0, + "hasDirectFiles": False, + "hasChildren": False, + "showSelfToggle": False, + } + node_map[key] = node + parent["children"].append(node) + node_map[path]["count"] = len(idx_list) + direct_file_dirs = set() + for idx in repo_records_map.get(repo, []): + rec = records[idx] + folders = rec.get("Folder", []) or [] + direct_file_dirs.add("/".join(folders)) + for path_key, node in node_map.items(): + node["hasDirectFiles"] = path_key in direct_file_dirs + node["children"].sort(key=lambda child: child["name"]) + node["hasChildren"] = len(node["children"]) > 0 + node["showSelfToggle"] = bool(path_key and node["hasDirectFiles"] and node["hasChildren"]) + return [tree_root] + +def get_folder_contents(repo: str, path: str) -> dict: + if repo in folder_browser_data: + repo_browser = folder_browser_data.get(repo, {}) or {} + if path in repo_browser: + return enrich_browser_entry(repo, path, repo_browser[path]) + if path == "" and "" in repo_browser: + return enrich_browser_entry(repo, path, repo_browser[""]) + path_parts = [p for p in path.strip("/").split("/") if p] + path_depth = len(path_parts) + repo_folders = folder_index.get(repo) + candidate_indices = (repo_folders.get("/".join(path_parts), []) + if repo_folders is not None else repo_records_map.get(repo, [])) + + def is_under(rec_folders, prefix): + return rec_folders[:len(prefix)] == prefix + matching = [records[i] for i in candidate_indices + if is_under(records[i].get("Folder", []) or [], path_parts)] + subfolder_names = set() + subfolder_counts = {} + for rec in matching: + folders = rec.get("Folder", []) or [] + if len(folders) > path_depth: + name = folders[path_depth] + subfolder_names.add(name) + subfolder_counts[name] = subfolder_counts.get(name, 0) + 1 + result_folders = [] + for fname in sorted(subfolder_names): + full_path = "/".join(path_parts + [fname]) if path_parts else fname + result_folders.append({"name": fname, "path": full_path, "count": subfolder_counts.get(fname, 0)}) + direct_files = [] + for rec in matching: + folders = rec.get("Folder", []) or [] + if len(folders) == path_depth: + direct_files.append({ + "name": rec.get("File", ""), + "ext": rec.get("Extension", ""), + "link": rec.get("Link") or build_record_link(rec), + "path": rec.get("Path") or build_record_path_url(rec), + "hasTxt": rec.get("HasTxt", False), + "size": rec.get("Size", ""), + }) + direct_files.sort(key=lambda f: f["name"]) + return {"folders": result_folders, "files": direct_files, "current_path": path} + +DOWNLOAD_CONNECTION_WARM_INTERVAL_SECONDS = 30 + + +async def warm_download_connection(app: FastAPI): + # Use the download pool, but never queue speculative work behind real transfers. + semaphore = app.state.upstream_semaphore + if semaphore.locked(): + return + try: + async with semaphore: + async with app.state.http_session.head( + "https://huggingface.co/robots.txt", + allow_redirects=False, + timeout=aiohttp.ClientTimeout(total=4, connect=3, sock_read=3), + ): + pass + except (aiohttp.ClientError, asyncio.TimeoutError): + # A failed warmup must not affect ping or the later download attempt. + pass + + +def schedule_download_connection_warm(app: FastAPI): + state = app.state + if not getattr(state, "download_warm_enabled", False): + return + task = state.download_warm_task + now = time.monotonic() + if task is not None and not task.done(): + return + if now - state.download_warm_at < DOWNLOAD_CONNECTION_WARM_INTERVAL_SECONDS: + return + state.download_warm_at = now + state.download_warm_task = asyncio.create_task(warm_download_connection(app)) + + +async def warm_txt_proxy_loop(app: FastAPI): + while True: + await asyncio.sleep(TXT_WARM_INTERVAL_SECONDS) + if time.monotonic() - last_user_request_at < TXT_WARM_IDLE_SECONDS: + continue + pool = txt_record_indices + if not pool: + continue + rec = records[random.choice(pool)] + remote_path = build_txt_relative_path(rec) + remote_url = TXT_SPACE_RAW_BASE + "/" + quote(remote_path, safe="/") + try: + async with app.state.http_session.get( + remote_url, + headers={"Range": "bytes=0-0", "Accept-Encoding": "identity"}, + timeout=aiohttp.ClientTimeout(total=15, connect=5, sock_read=5), + ) as response: + await response.content.read(1) + except asyncio.CancelledError: + raise + except Exception as e: + print(f"txt 预热异常: {e}") +@asynccontextmanager + +async def lifespan(app: FastAPI): + global last_user_request_at + load_data() + app.state.search_executor = SearchExecutor() + app.state.reader_index_build_task = None + app.state.reader_index_warm_task = None + app.state.http_session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=300, connect=15), + connector=aiohttp.TCPConnector(keepalive_timeout=60, ttl_dns_cache=300), + headers={"User-Agent": "VoiceOfML-Search/1.0"}, + auto_decompress=True, + ) + app.state.upstream_semaphore = asyncio.Semaphore(8) + app.state.download_warm_enabled = True + app.state.download_warm_task = None + app.state.download_warm_at = float("-inf") + app.state.reader_assets_lock = asyncio.Lock() + app.state.reader_assets_task = None + initialize_reader_bucket_state(app) + initialize_reader_archive_state(app) + app.state.reader_range_cache = OrderedDict() + app.state.reader_range_cache_bytes = 0 + app.state.reader_range_flights = {} + app.state.reader_range_flight_owners = set() + last_user_request_at = time.monotonic() + app.state.reader_index_warm_task = asyncio.create_task(warm_reader_indexes()) + app.state.txt_warm_task = asyncio.create_task(warm_txt_proxy_loop(app)) + try: + yield + finally: + if app.state.reader_index_warm_task is not None: + app.state.reader_index_warm_task.cancel() + await asyncio.gather(app.state.reader_index_warm_task, return_exceptions=True) + app.state.reader_index_warm_task = None + if app.state.reader_index_build_task is not None: + app.state.reader_index_build_task.cancel() + await asyncio.gather(app.state.reader_index_build_task, return_exceptions=True) + app.state.reader_index_build_task = None + await app.state.search_executor.close() + del app.state.search_executor + if app.state.reader_assets_task is not None: + app.state.reader_assets_task.cancel() + await asyncio.gather(app.state.reader_assets_task, return_exceptions=True) + app.state.reader_assets_task = None + app.state.download_warm_enabled = False + if app.state.download_warm_task is not None: + app.state.download_warm_task.cancel() + await asyncio.gather(app.state.download_warm_task, return_exceptions=True) + app.state.download_warm_task = None + app.state.txt_warm_task.cancel() + try: + await app.state.txt_warm_task + except asyncio.CancelledError: + pass + await shutdown_reader_bucket_state(app) + await shutdown_reader_archive_state(app) + await shutdown_reader_range_flights() + app.state.reader_range_cache.clear() + app.state.reader_range_cache_bytes = 0 + await app.state.http_session.close() +app = FastAPI(title="VoiceOfML Search", version="2.0", lifespan=lifespan) +app.add_middleware(GZipMiddleware, minimum_size=500) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["Accept-Ranges", "Content-Range", "Content-Length", "ETag", "Last-Modified", "Server-Timing"], +) + +@app.middleware("http") +async def track_user_activity(request: Request, call_next): + global last_user_request_at + if request.url.path not in ("/health", "/favicon.ico", "/api/ping"): + last_user_request_at = time.monotonic() + return await call_next(request) + +SearchText = Annotated[str, Field(max_length=256)] +SearchListItem = Annotated[str, Field(max_length=512)] +SearchList = Annotated[list[SearchListItem], Field(max_length=100)] +FolderSearchList = Annotated[list[SearchListItem], Field(max_length=10000)] + + +class SearchRequest(BaseModel): + q: SearchText = "" + repos: Optional[SearchList] = None + extensions: Optional[SearchList] = None + folders: Optional[FolderSearchList] = None + folder_match_mode: Literal["prefix", "exact", "mixed"] = "prefix" + folder_selfs: Optional[FolderSearchList] = None + folder_subtrees: Optional[FolderSearchList] = None + min_size: Optional[int] = Field(default=None, ge=0) + max_size: Optional[int] = Field(default=None, ge=0) + page: int = Field(default=1, ge=1) + page_size: int = Field(default=100, ge=1, le=500) + search_folders: bool = True + sort: Literal["relevance", "name", "size"] = "relevance" + exact: bool = False + anchor_id: Optional[str] = Field(default=None, max_length=16384) + + +def normalize_search_list(values: list[str] | None, lowercase: bool = False) -> list[str] | None: + if not values: + return None + return list(dict.fromkeys(value.strip().lower() if lowercase else value.strip() for value in values)) + + +async def build_search_api_response(body: SearchRequest, repos: list[str] | None) -> Response: + params = body.model_dump(exclude={"repos"}) + for name in ("extensions", "folders", "folder_selfs", "folder_subtrees"): + params[name] = normalize_search_list(params[name], lowercase=name == "extensions") + compute = functools.partial(search, repos=repos, **params) + try: + executor = getattr(app.state, "search_executor", None) + if executor is None: + # Direct endpoint tests without ASGI lifespan own a temporary pool. + async with SearchExecutor() as executor: + payload, queued_ms, search_ms = await executor.run(compute) + else: + payload, queued_ms, search_ms = await executor.run(compute) + except SearchBusy: + return JSONResponse({"error": "搜索繁忙,请稍后重试"}, status_code=503, + headers={"Retry-After": "1"}) + started = time.perf_counter() + assets = await get_reader_assets(wait=False) + assets_ms = (time.perf_counter() - started) * 1000 + started = time.perf_counter() + key = ("search", _data_generation, reader_assets_revision, READER_ASSET_ROUTING_CONTRACT_VERSION, tuple(repos or ()), + json.dumps(params, sort_keys=True, ensure_ascii=False)) + response = cached_json_response(key, lambda: add_reader_assets(payload, assets)) + response.headers["Server-Timing"] = ( + f"queue;dur={queued_ms:.2f}, search;dur={search_ms:.2f}, " + f"reader_assets;dur={assets_ms:.2f}, response;dur={(time.perf_counter() - started) * 1000:.2f}") + return response + + +@app.post("/api/search") +async def api_search(body: SearchRequest): + return await build_search_api_response(body, normalize_search_list(body.repos)) + + +@app.post("/api/search/{repo_name}") +async def api_search_repo(repo_name: str, body: SearchRequest): + full_repo = f"VoiceOfML/{repo_name}" + if full_repo not in repo_counts: + return JSONResponse({"error": f"仓库 {full_repo} 不存在", "results": [], "total": 0}, status_code=404) + return await build_search_api_response(body, [full_repo]) + + +@app.get("/api/repos") + +def api_repos(): + return cached_json_response(("repos", _data_generation), lambda: repo_list, METADATA_CACHE_HEADERS) +@app.get("/api/reader-assets") + +async def api_reader_assets(): + assets = await get_reader_assets() + cache_control = "public, max-age=300" if assets.get("f") else "no-store" + return JSONResponse(assets, headers={"Cache-Control": cache_control}) +@app.get("/api/reader-v3-status") +async def api_reader_v3_status(): + assets = await get_reader_assets() + metadata = assets.get("v3") + return JSONResponse({"version": 1, "surface": "hf", "active": metadata is not None, + **(metadata or {})}, headers={"Cache-Control": "no-store"}) +@app.get("/api/extensions") + +def api_extensions(repo: Optional[str] = Query(default=None)): + def build(): + if repo: + full_repo = f"VoiceOfML/{repo}" + ext_count = repo_extension_counts.get(full_repo, {}) + else: + ext_count = extension_counts + sorted_exts = sorted(ext_count.keys()) + return [{"name": e, "count": ext_count[e]} for e in sorted_exts] + return cached_json_response(("extensions", _data_generation, repo or ""), build, METADATA_CACHE_HEADERS) +@app.get("/api/folders/{repo_name}") + +def api_folders(repo_name: str): + full_repo = f"VoiceOfML/{repo_name}" + if full_repo not in repo_counts: + return JSONResponse({"error": f"仓库 {full_repo} 不存在"}, status_code=404) + return cached_json_response(("folders", _data_generation, full_repo), lambda: build_filter_folder_tree(full_repo)) +@app.get("/api/folders/{repo_name}/contents") + +def api_folder_contents(repo_name: str, path: str = Query(default="")): + full_repo = f"VoiceOfML/{repo_name}" + if full_repo not in repo_counts: + return JSONResponse({"error": f"仓库 {full_repo} 不存在"}, status_code=404) + return cached_json_response(("folder_contents", _data_generation, full_repo, path), lambda: get_folder_contents(full_repo, path)) +@app.get("/api/random") + +def api_random(repo: Optional[str] = Query(default=None)): + if repo: + full_repo = f"VoiceOfML/{repo}" + repo_indices = repo_records_map.get(full_repo, []) + if not repo_indices: + return JSONResponse({"error": "无可用记录"}, status_code=404) + rec = public_record(records[random.choice(repo_indices)]) + else: + if not records: + return JSONResponse({"error": "无可用记录"}, status_code=404) + rec = public_record(random.choice(records)) + return JSONResponse(rec) + +@app.get("/api/random-txt") +def api_random_txt(repo: Optional[str] = Query(default=None)): + if repo: + full_repo = f"VoiceOfML/{repo}" + pool = repo_txt_record_indices.get(full_repo, []) + else: + pool = txt_record_indices + if not pool: + return JSONResponse({"error": "无可用 txt"}, status_code=404) + return JSONResponse(public_record(records[random.choice(pool)])) + +@app.get("/api/random-txt/status") +def api_random_txt_status(repo: Optional[str] = Query(default=None)): + if repo: + full_repo = f"VoiceOfML/{repo}" + count = len(repo_txt_record_indices.get(full_repo, [])) + else: + count = len(txt_record_indices) + return JSONResponse({"available": count > 0, "count": count}, headers=METADATA_CACHE_HEADERS) + +def converted_reader_indices(assets: dict, full_repo: str | None = None) -> dict[int, dict]: + global reader_join_cache_key, reader_join_cache + ensure_reader_indexes() + cache_key = (_data_generation, reader_assets_revision, READER_ASSET_ROUTING_CONTRACT_VERSION) + if reader_join_cache_key != cache_key: + converted = {} + for key, asset in assets.get("f", {}).items(): + if not isinstance(asset, dict) or asset.get("s") != 2: + continue + index = reader_record_key_indices.get(key) + if index is not None: + converted[index] = asset + by_repo = {} + for index, asset in converted.items(): + repo = records[index].get("Repo", "") + by_repo.setdefault(repo, {})[index] = asset + reader_join_cache = {"all": converted, "repos": by_repo} + reader_join_cache_key = cache_key + if full_repo: + return reader_join_cache["repos"].get(full_repo, {}) + return reader_join_cache["all"] + + +async def get_reader_pool(repo: str | None = None) -> tuple[list[int], dict[int, dict]]: + await ensure_reader_indexes_async() + full_repo = f"VoiceOfML/{repo}" if repo else None + original = repo_reader_record_indices.get(full_repo, []) if full_repo else reader_record_indices + # Random selection uses the complete last published snapshot while refresh + # proceeds; a cold process must still await its first asset generation. + assets = await get_reader_assets(wait=not bool(reader_assets_cache.get("f"))) + converted = converted_reader_indices(assets, full_repo) + pool = dict.fromkeys(original) + pool.update(dict.fromkeys(converted)) + return list(pool), converted + + +@app.get("/api/random-reader") +async def api_random_reader(repo: Optional[str] = Query(default=None)): + started = time.perf_counter() + pool, converted = await get_reader_pool(repo) + if not pool: + return JSONResponse({"error": "无可读文档"}, status_code=404) + index = random.choice(pool) + rec = public_record(records[index]) + asset = converted.get(index) + if asset: + rec = enrich_reader_record(rec, asset) + return JSONResponse(rec, headers={"Server-Timing": f"reader_select;dur={(time.perf_counter() - started) * 1000:.2f}"}) + +@app.get("/api/random-reader/status") +async def api_random_reader_status(repo: Optional[str] = Query(default=None)): + pool, _ = await get_reader_pool(repo) + count = len(pool) + return JSONResponse({"available": count > 0, "count": count}, headers=METADATA_CACHE_HEADERS) + +@app.get("/api/bootstrap") +def api_bootstrap(): + return JSONResponse( + cached_payload(("bootstrap", _data_generation), build_global_bootstrap), + headers=METADATA_CACHE_HEADERS, + ) + +@app.get("/api/ping") +async def api_ping(): + schedule_download_connection_warm(app) + return PlainTextResponse(status_code=204, headers={"Cache-Control": "no-store"}) + + +READER_UPSTREAM_RESPONSE_HEADERS = ( + "Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", + "ETag", "Last-Modified", "Cache-Control", +) +READER_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable" +READER_ISOLATION_HEADERS = { + "Content-Security-Policy": "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'", + "X-Content-Type-Options": "nosniff", +} +READER_DOCUMENT_MAX_BYTES = 512 * 1024 * 1024 +READER_RESOURCE_MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 +READER_RESOURCE_MAX_BYTES = 64 * 1024 * 1024 +READER_BUCKET_CACHE_TTL_SECONDS = 3600 +READER_BUCKET_CACHE_MAX_BYTES = 32 * 1024 * 1024 +READER_BUCKET_CACHE_MAX_ITEM_BYTES = 8 * 1024 * 1024 +READER_BUCKET_MAX_CONCURRENT_READS = 8 +READER_BUCKET_MAX_INFLIGHT = 32 +READER_OCR_CACHE_MAX_BYTES = 32 * 1024 * 1024 +READER_OCR_CACHE_MAX_ITEMS = 4 +READER_ARCHIVE_MAX_ENTRIES = 20_000 +READER_ARCHIVE_MAX_EXPANDED_BYTES = 1536 * 1024 * 1024 +READER_ARCHIVE_MAX_COMPRESSION_RATIO = 200 +READER_ARCHIVE_MAX_CENTRAL_DIRECTORY_BYTES = 8 * 1024 * 1024 +READER_ARCHIVE_MAX_METADATA_BYTES = 16 * 1024 * 1024 +READER_ARCHIVE_CACHE_TTL_SECONDS = 900 +READER_ARCHIVE_CACHE_MAX_BYTES = 32 * 1024 * 1024 +READER_ARCHIVE_CACHE_MAX_ENTRIES = 4 +READER_ARCHIVE_MAX_CONCURRENT_LOADS = 2 +READER_ARCHIVE_MAX_INFLIGHT = 16 +READER_ARCHIVE_MAX_WORKERS = 2 +READER_ARCHIVE_MEMBER_RE = re.compile(r"^[^/\x00]+(?:/[^/\x00]+)*(?:/)?$") +READER_RANGE_CACHE_TTL_SECONDS = 900 +READER_RANGE_CACHE_MAX_BYTES = 32 * 1024 * 1024 +READER_RANGE_CACHE_MAX_ITEM_BYTES = 2 * 1024 * 1024 +READER_RANGE_CACHE_MAX_ENTRIES = 256 +READER_RANGE_MAX_FLIGHTS = 32 + + +class ReaderArchiveLimitError(ValueError): + pass + + +class ReaderArchiveInvalidError(ValueError): + pass + + +class ReaderDocumentLimitError(ValueError): + pass + + +def validate_reader_archive(archive: bytes) -> None: + if len(archive) > READER_RESOURCE_MAX_ARCHIVE_BYTES: + raise ReaderArchiveLimitError("阅读文件过大") + try: + # Use the stdlib's EOCD/ZIP64 parser before ZipFile allocates its directory. + end_record = zipfile._EndRecData(io.BytesIO(archive)) + if not end_record: + raise ReaderArchiveInvalidError("阅读文件不是有效 ZIP") + central_directory_size = end_record[zipfile._ECD_SIZE] + directory_offset = end_record[zipfile._ECD_OFFSET] + entry_count = end_record[zipfile._ECD_ENTRIES_TOTAL] + if (end_record[zipfile._ECD_DISK_NUMBER] != 0 + or end_record[zipfile._ECD_DISK_START] != 0 + or end_record[zipfile._ECD_ENTRIES_THIS_DISK] != entry_count + or len(end_record[zipfile._ECD_COMMENT]) != end_record[zipfile._ECD_COMMENT_SIZE] + or (end_record[zipfile._ECD_SIGNATURE] != zipfile.stringEndArchive64 + and (central_directory_size == 0xffffffff or directory_offset == 0xffffffff))): + raise ReaderArchiveInvalidError("阅读文件元数据无效") + if central_directory_size > READER_ARCHIVE_MAX_CENTRAL_DIRECTORY_BYTES: + raise ReaderArchiveLimitError("阅读文件元数据过大") + if entry_count > READER_ARCHIVE_MAX_ENTRIES: + raise ReaderArchiveLimitError("阅读文件条目过多") + if directory_offset + central_directory_size > len(archive): + raise ReaderArchiveInvalidError("阅读文件元数据无效") + with zipfile.ZipFile(io.BytesIO(archive)) as book_zip: + infos = book_zip.infolist() + if len(infos) > READER_ARCHIVE_MAX_ENTRIES: + raise ReaderArchiveLimitError("阅读文件条目过多") + + if len(infos) != entry_count: + raise ReaderArchiveInvalidError("阅读文件条目数无效") + central_directory_offset = book_zip.start_dir + archive_comment = book_zip.comment + if ( + not isinstance(central_directory_size, int) + or isinstance(central_directory_size, bool) + or central_directory_size < 0 + or central_directory_size > READER_ARCHIVE_MAX_CENTRAL_DIRECTORY_BYTES + or not isinstance(central_directory_offset, int) + or isinstance(central_directory_offset, bool) + or central_directory_offset < 0 + or central_directory_offset > len(archive) + or central_directory_size > len(archive) - central_directory_offset + or not isinstance(archive_comment, bytes) + ): + raise ReaderArchiveInvalidError("阅读文件元数据无效") + + expanded = 0 + compressed = 0 + metadata_bytes = len(archive_comment) + for info in infos: + filename = info.orig_filename + extra = info.extra + comment = info.comment + if not isinstance(filename, str) or not isinstance(extra, bytes) or not isinstance(comment, bytes): + raise ReaderArchiveInvalidError("阅读文件元数据无效") + name = filename.replace("\\", "/") + name_parts = name.split("/") + if (not name or name.startswith("/") or "\x00" in name + or any(part in {".", ".."} for part in name_parts) + or any(not part for part in name_parts[:-1]) + or not READER_ARCHIVE_MEMBER_RE.fullmatch(name)): + raise ReaderArchiveInvalidError("阅读文件包含不安全路径") + try: + filename_bytes = len(filename.encode("utf-8", "surrogatepass")) + except (UnicodeError, AttributeError) as exc: + raise ReaderArchiveInvalidError("阅读文件元数据无效") from exc + metadata_bytes += 46 + filename_bytes + len(extra) + len(comment) + if metadata_bytes > READER_ARCHIVE_MAX_METADATA_BYTES: + raise ReaderArchiveLimitError("阅读文件元数据过大") + + file_size = info.file_size + compressed_size = info.compress_size + header_offset = info.header_offset + if ( + not isinstance(file_size, int) + or isinstance(file_size, bool) + or file_size < 0 + or not isinstance(compressed_size, int) + or isinstance(compressed_size, bool) + or compressed_size < 0 + or compressed_size > len(archive) + or not isinstance(header_offset, int) + or isinstance(header_offset, bool) + or header_offset < 0 + or header_offset >= len(archive) + or header_offset >= central_directory_offset + ): + raise ReaderArchiveInvalidError("阅读文件条目无效") + expanded += file_size + compressed += compressed_size + if expanded > READER_ARCHIVE_MAX_EXPANDED_BYTES: + raise ReaderArchiveLimitError("阅读文件展开后过大") + if file_size and (compressed_size == 0 or file_size > compressed_size * READER_ARCHIVE_MAX_COMPRESSION_RATIO): + raise ReaderArchiveLimitError("阅读文件压缩比过高") + + if compressed == 0: + if expanded: + raise ReaderArchiveLimitError("阅读文件压缩比过高") + elif expanded > compressed * READER_ARCHIVE_MAX_COMPRESSION_RATIO: + raise ReaderArchiveLimitError("阅读文件压缩比过高") + if compressed > len(archive): + raise ReaderArchiveInvalidError("阅读文件条目无效") + except (ReaderArchiveLimitError, ReaderArchiveInvalidError): + raise + except Exception as exc: + raise ReaderArchiveInvalidError("阅读文件不是有效 ZIP") from exc + + +READER_RESOURCE_TYPES = { + "json": "application/json", "xhtml": "application/xhtml+xml", "html": "text/html", "txt": "text/plain", + "md": "text/markdown", "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "pdf": "application/pdf", "epub": "application/epub+zip", + "css": "text/css", "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", + "gif": "image/gif", "bmp": "image/bmp", "webp": "image/webp", "jxl": "image/jxl", "svg": "image/svg+xml", "ttf": "font/ttf", + "otf": "font/otf", "woff": "font/woff", "woff2": "font/woff2", "mp3": "audio/mpeg", + "wav": "audio/wav", "m4a": "audio/mp4", "flac": "audio/flac", "mpga": "audio/mpeg", + "mp4": "video/mp4", "mov": "video/quicktime", "swf": "application/x-shockwave-flash", "gz": "application/gzip", +} +def initialize_reader_bucket_state(target_app: FastAPI) -> None: + target_app.state.reader_bucket_cache = OrderedDict() + target_app.state.reader_bucket_cache_bytes = 0 + target_app.state.reader_bucket_inflight = {} + target_app.state.reader_bucket_workers = set() + target_app.state.reader_bucket_semaphore = asyncio.Semaphore(READER_BUCKET_MAX_CONCURRENT_READS) + target_app.state.reader_ocr_cache = OrderedDict() + target_app.state.reader_ocr_cache_bytes = 0 + + +async def shutdown_reader_bucket_state(target_app: FastAPI) -> None: + tasks = list(target_app.state.reader_bucket_inflight.values()) + target_app.state.reader_bucket_inflight.clear() + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + workers = list(target_app.state.reader_bucket_workers) + if workers: + await asyncio.gather(*workers, return_exceptions=True) + target_app.state.reader_bucket_cache.clear() + target_app.state.reader_bucket_cache_bytes = 0 + target_app.state.reader_ocr_cache.clear() + target_app.state.reader_ocr_cache_bytes = 0 + + +def is_reader_bucket_cacheable(path: str) -> bool: + return ( + path.endswith("/page-manifest.json") or path.endswith("/ocr-manifest.json") + or path.endswith("/ocr/book-text.json.gz") or path.endswith("/pages/page-000001.webp") + or (path.startswith("objects/") and path.rsplit("/", 1)[-1].startswith("document.")) + or (path.startswith("chapters/ebook/") and any( + path.endswith(suffix) for suffix in ( + "/chapter-manifest.json", "/epub-search-index.json.gz", + ))) + ) + + +def reader_bucket_cache_control(path: str) -> str: + # Page paths contain the source digest and an optional asset version, so a + # generated image can be cached immutably even though the manifest moves. + if re.search(r"/pages/page-[0-9]{6}\.(?:webp|jxl)$", path): + return "public, max-age=31536000, immutable" + if re.fullmatch(r"objects/[0-9a-f]{2}/[0-9a-f]{64}/[0-9a-f]{16}/(?:preview|text)/.+", path): + return "public, max-age=31536000, immutable" + if path.startswith("objects/") and path.rsplit("/", 1)[-1].startswith("document."): + return "public, max-age=31536000, immutable" + if "/ebook-chapters/" in path: + return "public, max-age=31536000, immutable" + return "public, max-age=3600" + + +def reader_bucket_for_path(path: str) -> str: + if (path.startswith(("documents/", "chapters/", "media/", "pages/image/", "native/"))): + return "vomebook/reader-assets-v2" + return "vomebook/pdf-pages-v2" + + +def reader_bucket_source_url(path: str) -> str: + bucket = reader_bucket_for_path(path) + return f"https://huggingface.co/buckets/{bucket}/resolve/{quote(path, safe='/')}" + + +def validate_reader_bucket_redirect_url(url: str, source_url: str) -> str: + validate_download_url(url) + if urlparse(url).hostname == "huggingface.co" and url != source_url: + raise ValueError("上游重定向超出允许的阅读资源范围") + return url + + +def read_reader_bucket_resource(path: str, max_bytes: int = READER_RESOURCE_MAX_BYTES) -> bytes: + filesystem = HfFileSystem() + bucket = reader_bucket_for_path(path) + with filesystem.open(f"hf://buckets/{bucket}/{path}", "rb") as stream: + payload = stream.read(max_bytes + 1) + if len(payload) > max_bytes: + raise ValueError("阅读资源过大") + return payload + + +async def get_reader_bucket_resource(path: str, max_bytes: int = READER_RESOURCE_MAX_BYTES) -> bytes: + state = app.state + cache_key = path + if is_reader_bucket_cacheable(path): + cached = state.reader_bucket_cache.get(cache_key) + if cached: + cached_at, payload = cached + if time.monotonic() - cached_at < READER_BUCKET_CACHE_TTL_SECONDS: + state.reader_bucket_cache.move_to_end(cache_key) + return payload + state.reader_bucket_cache.pop(cache_key) + state.reader_bucket_cache_bytes -= len(payload) + + task = state.reader_bucket_inflight.get(cache_key) + if task is None: + if len(state.reader_bucket_inflight) >= READER_BUCKET_MAX_INFLIGHT: + raise RuntimeError("too many reader bucket requests") + async def load() -> bytes: + try: + payload = await run_reader_bucket_read(path, max_bytes) + if is_reader_bucket_cacheable(path) and len(payload) <= READER_BUCKET_CACHE_MAX_ITEM_BYTES: + previous = state.reader_bucket_cache.pop(cache_key, None) + if previous: + state.reader_bucket_cache_bytes -= len(previous[1]) + state.reader_bucket_cache[cache_key] = (time.monotonic(), payload) + state.reader_bucket_cache_bytes += len(payload) + while state.reader_bucket_cache_bytes > READER_BUCKET_CACHE_MAX_BYTES: + _, (_, evicted) = state.reader_bucket_cache.popitem(last=False) + state.reader_bucket_cache_bytes -= len(evicted) + return payload + finally: + if state.reader_bucket_inflight.get(cache_key) is asyncio.current_task(): + state.reader_bucket_inflight.pop(cache_key, None) + + task = asyncio.create_task(load()) + task.add_done_callback(lambda done: None if done.cancelled() else done.exception()) + state.reader_bucket_inflight[cache_key] = task + return await asyncio.shield(task) + + +async def run_reader_bucket_read(path: str, max_bytes: int) -> bytes: + state = app.state + semaphore = state.reader_bucket_semaphore + await semaphore.acquire() + worker = asyncio.create_task(asyncio.to_thread(read_reader_bucket_resource, path, max_bytes)) + state.reader_bucket_workers.add(worker) + + def finished(done): + # Cancelling an async caller cannot stop its filesystem thread. + state.reader_bucket_workers.discard(done) + semaphore.release() + if not done.cancelled(): + done.exception() + + worker.add_done_callback(finished) + return await asyncio.shield(worker) + + +def parse_reader_ocr_book(raw: bytes) -> tuple[list[dict], int]: + """Decode the single v2 book text index and its layout mappings.""" + with gzip.GzipFile(fileobj=io.BytesIO(raw)) as stream: + decoded = stream.read(READER_RESOURCE_MAX_BYTES + 1) + if len(decoded) > READER_RESOURCE_MAX_BYTES: + raise ValueError("PDF OCR text index is too large") + payload = json.loads(decoded) + pages = payload.get("pages") if isinstance(payload, dict) else None + if (not isinstance(pages, list) or len(pages) > 200000 + or payload.get("version") != 2 or payload.get("kind") != "pdf-book-text" + or payload.get("complete") is not True + or payload.get("offset_unit") != "unicode-codepoint" + or payload.get("page_count") != len(pages) + or not re.fullmatch(r"[0-9a-f]{64}", str(payload.get("source_sha256", "")))): + raise ValueError("invalid PDF OCR text index") + output = [] + total_bytes = 0 + previous = 0 + for item in pages: + if (not isinstance(item, dict) or not isinstance(item.get("page"), int) + or item["page"] < 1 or item["page"] <= previous + or not isinstance(item.get("text"), str)): + raise ValueError("invalid PDF OCR text page") + text = item["text"] + total_bytes += len(text.encode("utf-8")) + if total_bytes > READER_RESOURCE_MAX_BYTES: + raise ValueError("PDF OCR text index is too large") + layout = item.get("layout") + spans = item.get("text_spans") + if (not isinstance(layout, dict) or layout.get("offset_unit") != "unicode-codepoint" + or not isinstance(spans, list)): + raise ValueError("invalid PDF text layout mapping") + previous_span_end = 0 + for span in spans: + box = span.get("box") if isinstance(span, dict) else None + if (not isinstance(span, dict) or not isinstance(span.get("start"), int) + or not isinstance(span.get("end"), int) or span["start"] < previous_span_end + or span["end"] <= span["start"] or span["end"] > len(text) + or not isinstance(box, list) or len(box) != 4 + or any(not isinstance(value, (int, float)) or value < 0 or value > 1 for value in box)): + raise ValueError("invalid PDF text span") + previous_span_end = span["end"] + output.append({"page": item["page"], "text": text, "folded": text.casefold(), + "layout": layout, "spans": spans}) + previous = item["page"] + return output, total_bytes + + +async def get_reader_ocr_book(path: str) -> list[dict]: + state = app.state + if not hasattr(state, "reader_ocr_cache"): + state.reader_ocr_cache = OrderedDict() + state.reader_ocr_cache_bytes = 0 + cached = state.reader_ocr_cache.get(path) + if cached: + cached_at, pages, size = cached + if time.monotonic() - cached_at < READER_BUCKET_CACHE_TTL_SECONDS: + state.reader_ocr_cache.move_to_end(path) + return pages + state.reader_ocr_cache.pop(path) + state.reader_ocr_cache_bytes -= size + raw = await get_reader_bucket_resource(path) + pages, size = await asyncio.to_thread(parse_reader_ocr_book, raw) + if size <= READER_OCR_CACHE_MAX_BYTES: + previous = state.reader_ocr_cache.pop(path, None) + if previous: + state.reader_ocr_cache_bytes -= previous[2] + state.reader_ocr_cache[path] = (time.monotonic(), pages, size) + state.reader_ocr_cache_bytes += size + while (len(state.reader_ocr_cache) > READER_OCR_CACHE_MAX_ITEMS + or state.reader_ocr_cache_bytes > READER_OCR_CACHE_MAX_BYTES): + _, (_, _, evicted_size) = state.reader_ocr_cache.popitem(last=False) + state.reader_ocr_cache_bytes -= evicted_size + return pages + + +@app.api_route("/api/reader-bucket-resource", methods=["GET", "HEAD"]) +async def api_reader_bucket_resource(path: str = Query(...), request: Request = None): + resource_path = unquote(unquote(path)).replace("\\", "/").lstrip("/") + if not READER_BUCKET_PATH_RE.fullmatch(resource_path): + return JSONResponse({"error": "不允许的阅读资源路径"}, status_code=400, + headers={"Cache-Control": "no-store"}) + suffix = resource_path.rsplit(".", 1)[-1].lower() + media_type = READER_RESOURCE_TYPES.get(suffix, "application/octet-stream") + # Uncached body resources must release transport on close, including office, + # text and chapter bodies. Shared metadata/index cache reads remain bounded. + if request is not None and (suffix == "pdf" or media_type.startswith(("audio/", "video/", "image/")) + or not is_reader_bucket_cacheable(resource_path)): + source_url = reader_bucket_source_url(resource_path) + return await stream_reader_document( + request, source_url, + url_validator=lambda url: validate_reader_bucket_redirect_url(url, source_url), + validation_scope=(validate_reader_bucket_redirect_url, source_url), + cache_control=reader_bucket_cache_control(resource_path), + max_bytes=(None if media_type.startswith(("audio/", "video/")) else + READER_DOCUMENT_MAX_BYTES if suffix == "pdf" else READER_RESOURCE_MAX_BYTES), + media_type=media_type, + ) + try: + payload = await get_reader_bucket_resource(resource_path) + except FileNotFoundError: + return JSONResponse({"error": "阅读资源不存在"}, status_code=404, + headers={"Cache-Control": "no-store"}) + except Exception: + return JSONResponse({"error": "阅读资源加载失败"}, status_code=502, + headers={"Cache-Control": "no-store"}) + return Response(content=b"" if request is not None and request.method == "HEAD" else payload, + media_type=media_type, + headers={**READER_ISOLATION_HEADERS, + "Cache-Control": reader_bucket_cache_control(resource_path), + "Content-Length": str(len(payload)), "Content-Encoding": "identity"}) + + +def initialize_reader_archive_state(target_app: FastAPI) -> None: + target_app.state.reader_archive_cache = OrderedDict() + target_app.state.reader_archive_inflight = {} + target_app.state.reader_archive_workers = set() + target_app.state.reader_archive_load_semaphore = asyncio.Semaphore(READER_ARCHIVE_MAX_CONCURRENT_LOADS) + target_app.state.reader_archive_worker_semaphore = asyncio.Semaphore(READER_ARCHIVE_MAX_WORKERS) + + +async def shutdown_reader_archive_state(target_app: FastAPI) -> None: + tasks = list(target_app.state.reader_archive_inflight.values()) + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + workers = list(target_app.state.reader_archive_workers) + if workers: + await asyncio.gather(*workers, return_exceptions=True) + target_app.state.reader_archive_inflight.clear() + target_app.state.reader_archive_cache.clear() + + +async def run_reader_archive_worker(function, *args): + state = app.state + semaphore = state.reader_archive_worker_semaphore + await semaphore.acquire() + task = asyncio.create_task(asyncio.to_thread(function, *args)) + state.reader_archive_workers.add(task) + + def finished(done): + # Cancelling a caller cannot stop its thread or free that thread's slot. + state.reader_archive_workers.discard(done) + semaphore.release() + if not done.cancelled(): + done.exception() + + task.add_done_callback(finished) + return await asyncio.shield(task) + + +def prepare_reader_archive(raw: bytes | bytearray) -> bytes: + archive = bytes(raw) + if archive.startswith(b"\x1f\x8b"): + try: + with gzip.GzipFile(fileobj=io.BytesIO(archive)) as stream: + archive = stream.read(READER_RESOURCE_MAX_ARCHIVE_BYTES + 1) + except (OSError, EOFError, zlib.error) as exc: + raise ReaderArchiveInvalidError("阅读文件不是有效 ZIP") from exc + validate_reader_archive(archive) + return archive + + +async def get_reader_archive(target_url: str) -> bytes: + state = app.state + cache = state.reader_archive_cache + cached = cache.get(target_url) + if cached: + if time.monotonic() - cached[0] < READER_ARCHIVE_CACHE_TTL_SECONDS: + cache.move_to_end(target_url) + return cached[1] + cache.pop(target_url) + task = state.reader_archive_inflight.get(target_url) + if task is None: + if len(state.reader_archive_inflight) >= READER_ARCHIVE_MAX_INFLIGHT: + raise HTTPException(503, "阅读资源请求过多") + + async def load(): + try: + async with state.reader_archive_load_semaphore: + async with state.upstream_semaphore: + upstream = await open_download_response( + state.http_session, target_url, + aiohttp.ClientTimeout(total=180, connect=15, sock_read=60), + url_validator=validate_reader_redirect_url, + ) + try: + if upstream.status != 200: + raise HTTPException(upstream.status, f"上游阅读文件加载失败: HTTP {upstream.status}") + chunks = bytearray() + async for chunk in upstream.content.iter_chunked(64 * 1024): + chunks.extend(chunk) + if len(chunks) > READER_RESOURCE_MAX_ARCHIVE_BYTES: + raise ReaderArchiveLimitError("阅读文件过大") + finally: + upstream.release() + archive = await run_reader_archive_worker(prepare_reader_archive, chunks) + if len(archive) <= READER_ARCHIVE_CACHE_MAX_BYTES: + cache[target_url] = (time.monotonic(), archive) + cache.move_to_end(target_url) + while (len(cache) > READER_ARCHIVE_CACHE_MAX_ENTRIES + or sum(len(item[1]) for item in cache.values()) > READER_ARCHIVE_CACHE_MAX_BYTES): + cache.popitem(last=False) + return archive + finally: + state.reader_archive_inflight.pop(target_url, None) + + task = asyncio.create_task(load()) + task.add_done_callback(lambda done: None if done.cancelled() else done.exception()) + state.reader_archive_inflight[target_url] = task + return await asyncio.shield(task) + + +def read_reader_archive_member(archive: bytes, resource_path: str) -> bytes: + with zipfile.ZipFile(io.BytesIO(archive)) as book_zip: + names = {name.replace("\\", "/"): name for name in book_zip.namelist()} + info = book_zip.getinfo(names[resource_path]) + if info.file_size > READER_RESOURCE_MAX_BYTES: + raise ReaderArchiveLimitError("阅读资源过大") + return book_zip.read(info) + + +@app.get("/api/reader-resource") +async def api_reader_resource(book: str = Query(...), path: str = Query(...)): + try: + target_url = validate_reader_source_url(book) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=403) + resource_path = path.replace("\\", "/") + if (not resource_path or len(resource_path) > 512 + or "\x00" in resource_path + or any(part in {"", ".", ".."} for part in resource_path.split("/"))): + return JSONResponse({"error": "不允许的阅读资源路径"}, status_code=400) + try: + archive = await get_reader_archive(target_url) + except HTTPException as exc: + return JSONResponse({"error": exc.detail}, status_code=exc.status_code, headers={"Cache-Control": "no-store"}) + except ReaderArchiveLimitError as exc: + return JSONResponse({"error": str(exc)}, status_code=413) + except ReaderArchiveInvalidError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + except Exception as exc: + return JSONResponse({"error": str(exc) or "上游阅读文件加载失败"}, status_code=502) + try: + payload = await run_reader_archive_worker(read_reader_archive_member, archive, resource_path) + except KeyError: + return JSONResponse({"error": "阅读资源不存在"}, status_code=404) + except ReaderArchiveLimitError as exc: + return JSONResponse({"error": str(exc)}, status_code=413) + except (zipfile.BadZipFile, EOFError, IndexError, OverflowError, + RuntimeError, NotImplementedError, OSError, zlib.error): + return JSONResponse({"error": "阅读资源损坏"}, status_code=400) + suffix = resource_path.rsplit(".", 1)[-1].lower() if "." in resource_path else "" + return Response(content=payload, media_type=READER_RESOURCE_TYPES.get(suffix, "application/octet-stream"), headers={**READER_ISOLATION_HEADERS, "Cache-Control": "public, max-age=3600"}) + + +@app.get("/api/reader-resolve") +async def api_reader_resolve(id: str = Query(..., min_length=13, max_length=13)): + await ensure_reader_indexes_async() + if not READER_SHORT_ID_RE.fullmatch(id): + return JSONResponse({"error": "无效阅读 ID"}, status_code=400) + # A cold resolution must await conversion metadata; otherwise unsupported + # originals (such as PS) become cached as the Reader's document source. + assets = await get_reader_assets(wait=not bool(reader_assets_cache.get("f"))) + matches = [] + for index in reader_source_id_indices.get(id, []): + record = records[index] + matches.append(reader_resolved_record(record, assets.get("f", {}).get(build_reader_asset_key(record), {}))) + if len({canonical_reader_source_url(match["download"]) for match in matches}) > 1: + return JSONResponse({"error": "阅读 ID 不唯一"}, status_code=409) + matches = matches[:1] + if matches: + return JSONResponse(matches[0], headers={"Cache-Control": + "public, max-age=300" if assets.get("f") else "no-store"}) + return JSONResponse({"error": "阅读文件不存在"}, status_code=404) + + +@app.get("/api/reader-ocr-search") +async def api_reader_ocr_search( + id: str = Query(..., min_length=13, max_length=13), + q: str = Query(..., min_length=1, max_length=256), + page: int = Query(1, ge=1, le=1000000), + page_size: int = Query(50, ge=1, le=100), +): + """Search one PDF's immutable OCR text without loading it into app memory.""" + resolved = await api_reader_resolve(id) + if resolved.status_code != 200: + return resolved + try: + data = json.loads(resolved.body) + manifest_url = str(data.get("ocr_manifest") or "") + parsed = urlparse(manifest_url) + parameters = parse_qs(parsed.query) + manifest_path = parameters.get("path", [""])[0] + if (parsed.path != "/api/reader-bucket-resource" or set(parameters) != {"path"} + or not READER_BUCKET_PATH_RE.fullmatch(manifest_path) + or not manifest_path.endswith("/ocr-manifest.json")): + return JSONResponse({"error": "本书暂无 OCR 索引"}, status_code=404) + manifest = json.loads(await get_reader_bucket_resource(manifest_path)) + book = manifest.get("book_text") if isinstance(manifest, dict) else None + book_path = book.get("path") if isinstance(book, dict) else "" + if (not isinstance(book_path, str) or not READER_BUCKET_PATH_RE.fullmatch(book_path) + or not book_path.endswith("/book-text.json.gz")): + return JSONResponse({"error": "OCR 索引损坏"}, status_code=502) + pages = await get_reader_ocr_book(book_path) + needle = q.casefold() + matches = [] + for item in pages: + page_number, text, folded = item["page"], item["text"], item["folded"] + start = 0 + while True: + found = folded.find(needle, start) + if found < 0: + break + end = found + len(q) + snippet_start = max(0, found - 72) + snippet_end = min(len(text), end + 88) + begin = found + end = found + len(q) + boxes = [span["box"] for span in item["spans"] + if span["end"] > begin and span["start"] < end] + matches.append({ + "page": page_number, "start": found, "length": len(q), + "snippet": text[snippet_start:snippet_end], + "snippet_start": found - snippet_start, "boxes": boxes, + }) + start = end + total = len(matches) + offset = (page - 1) * page_size + return JSONResponse({ + "version": 2, "kind": "pdf-book-text-search", "page": page, "page_size": page_size, "total": total, + "results": matches[offset:offset + page_size], + }, headers={"Cache-Control": "no-store"}) + except (ValueError, TypeError, KeyError, EOFError, gzip.BadGzipFile, zlib.error): + return JSONResponse({"error": "OCR 索引损坏"}, status_code=502) + + +@app.api_route("/api/reader-content", methods=["GET", "HEAD"]) +async def api_reader_content(request: Request, url: Optional[str] = Query(default=None), id: Optional[str] = Query(default=None)): + if id and not url: + resolved = await api_reader_resolve(id) + if resolved.status_code != 200: + return resolved + try: + url = json.loads(resolved.body).get("url") + except (AttributeError, TypeError, ValueError): + return JSONResponse({"error": "阅读文件解析失败"}, status_code=502) + if not url: + return JSONResponse({"error": "缺少阅读文件地址"}, status_code=400) + try: + parsed = urlparse(url) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=403) + is_bucket_proxy = ( + (not parsed.scheme and not parsed.netloc) + or (parsed.scheme == "https" and parsed.netloc == "voiceofml-search.hf.space") + ) + if is_bucket_proxy and parsed.path == "/api/reader-bucket-resource" and not parsed.fragment: + parameters = parse_qs(parsed.query) + if (set(parameters) != {"path"} or len(parameters["path"]) != 1): + return JSONResponse({"error": "不允许的阅读资源路径"}, status_code=400) + return await api_reader_bucket_resource(parameters["path"][0], request) + bucket_prefix = next((prefix for prefix in ( + "/buckets/vomebook/pdf-pages-v2/resolve/", + "/buckets/vomebook/reader-assets-v2/resolve/", + ) if parsed.path.startswith(prefix)), None) + if (parsed.scheme == "https" and parsed.hostname == "huggingface.co" + and parsed.netloc.lower() in {"huggingface.co", "huggingface.co:443"} + and not parsed.username and not parsed.password and not parsed.query and not parsed.fragment + and bucket_prefix is not None): + resource_path = unquote(parsed.path[len(bucket_prefix):]) + if not READER_BUCKET_PATH_RE.fullmatch(resource_path): + return JSONResponse({"error": "不允许的阅读资源路径"}, status_code=400) + return await api_reader_bucket_resource(resource_path, request) + try: + target_url = validate_reader_source_url(url) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=403) + source_type = READER_RESOURCE_TYPES.get(unquote(urlparse(target_url).path).rsplit(".", 1)[-1].lower(), "") + if source_type.startswith(("audio/", "video/")): + return await stream_reader_document(request, target_url, media_type=source_type) + return await stream_reader_document(request, target_url) + + +def reader_document_browser_headers(target_url: str, headers: dict, status: int) -> dict: + is_pdf = ( + headers.get("Content-Type", "").split(";", 1)[0].strip().lower() == "application/pdf" + or urlparse(target_url).path.lower().endswith(".pdf") + ) + if status == 206 and is_pdf: + # Keep browser partial bodies separate from the bounded server cache. + # The request-side PDF policy bypasses Chromium's same-URL cache lock. + return {**headers, "Cache-Control": "no-store"} + return headers + + +async def stream_reader_document( + request: Request, target_url: str, *, + url_validator=validate_reader_redirect_url, cache_control: str | None = None, + max_bytes: int | None = None, media_type: str | None = None, validation_scope=None, +): + options = dict(url_validator=url_validator, cache_control=cache_control, + max_bytes=max_bytes, media_type=media_type, validation_scope=validation_scope) + if not callable(getattr(request, "is_disconnected", None)): + return await _stream_reader_document(request, target_url, **options) + + stop_watching = asyncio.Event() + + async def disconnected(): + while not stop_watching.is_set(): + if await request.is_disconnected(): + return + if not stop_watching.is_set(): + await asyncio.sleep(0.1) + + # StreamingResponse handles disconnects after headers; own the wait for + # the upstream slot and headers too, so abandoned books cannot occupy it. + transport = asyncio.create_task(_stream_reader_document(request, target_url, **options)) + watcher = asyncio.create_task(disconnected()) + try: + await asyncio.wait((transport, watcher), return_when=asyncio.FIRST_COMPLETED) + if transport.done(): + return transport.result() + raise asyncio.CancelledError() + finally: + stop_watching.set() + watcher.cancel() + if not transport.done(): + transport.cancel() + await asyncio.gather(transport, watcher, return_exceptions=True) + + +async def _stream_reader_document( + request: Request, target_url: str, *, + url_validator=validate_reader_redirect_url, cache_control: str | None = None, + max_bytes: int | None = None, media_type: str | None = None, validation_scope=None, _share_range: bool = True, +): + # Native media is streamed, not parsed or buffered as a complete document. + # Bound transport memory and idle time instead of rejecting large recordings. + is_media_stream = bool(media_type and media_type.startswith(("audio/", "video/"))) + if max_bytes is None and not is_media_stream: + max_bytes = READER_DOCUMENT_MAX_BYTES + forwarded_headers = { + name: request.headers[name] + for name in ("Range", "If-Range", "If-None-Match", "If-Modified-Since") + if name in request.headers + } + range_header = forwarded_headers.get("Range") + # Conditional requests must reach the origin: a cached partial body cannot + # represent a 200/304 decision made against a different validator. + has_conditionals = any( + name in forwarded_headers for name in ("If-Range", "If-None-Match", "If-Modified-Since") + ) + range_key = ( + (target_url, range_header, max_bytes, media_type) + if request.method == "GET" and range_header and not has_conditionals + else None + ) + if range_key: + if not hasattr(app.state, "reader_range_cache"): + app.state.reader_range_cache = OrderedDict() + app.state.reader_range_cache_bytes = 0 + cached = app.state.reader_range_cache.get(range_key) + if cached: + cached_at, payload, cached_headers = cached + if time.monotonic() - cached_at < READER_RANGE_CACHE_TTL_SECONDS: + app.state.reader_range_cache.move_to_end(range_key) + return Response(content=payload, status_code=206, + headers=reader_proxy_timing( + reader_document_browser_headers(target_url, cached_headers, 206), cache="hit")) + app.state.reader_range_cache.pop(range_key, None) + app.state.reader_range_cache_bytes -= len(payload) + covered = reader_cached_subrange(range_key) + if covered is not None: + payload, cached_headers = covered + return Response(content=payload, status_code=206, headers=reader_proxy_timing( + reader_document_browser_headers(target_url, cached_headers, 206), cache="slice")) + requested = re.fullmatch(r"bytes=([0-9]{1,20})-([0-9]{1,20})", range_header) + if (_share_range and requested and int(requested[1]) <= int(requested[2]) + and int(requested[2]) - int(requested[1]) + 1 <= READER_RANGE_CACHE_MAX_ITEM_BYTES + and (urlparse(target_url).path.lower().endswith(".pdf") or media_type == "application/pdf")): + return await shared_reader_range_response(request, target_url, range_key, dict( + url_validator=url_validator, cache_control=cache_control, + max_bytes=max_bytes, media_type=media_type, validation_scope=validation_scope)) + semaphore = app.state.upstream_semaphore + queued_at = time.monotonic() + await semaphore.acquire() + acquired_at = time.monotonic() + lease = UpstreamLease(semaphore) + try: + upstream = lease.response = await open_download_response( + app.state.http_session, + target_url, + aiohttp.ClientTimeout(total=None if is_media_stream else 180, connect=15, sock_read=60), + method=request.method, + request_headers=forwarded_headers, + url_validator=url_validator, + auto_decompress=False, + ) + headers_at = time.monotonic() + # Range offsets and document limits describe identity bytes, not compressed wire bytes. + if upstream.headers.get("Content-Encoding", "identity").strip().lower() != "identity": + return JSONResponse({"error": "上游阅读文件编码无效"}, status_code=502) + headers = {name: upstream.headers[name] for name in READER_UPSTREAM_RESPONSE_HEADERS if name in upstream.headers} + headers.update(READER_ISOLATION_HEADERS) + headers["Cache-Control"] = cache_control or ( + READER_IMMUTABLE_CACHE_CONTROL + if is_immutable_reader_asset(target_url) + else headers.get("Cache-Control", "public, max-age=300") + ) + # Gecko treats any Content-Encoding header as compressed media and + # disables length/range seeking. GZipMiddleware already excludes media. + if is_media_stream: + headers.pop("Content-Encoding", None) + else: + headers["Content-Encoding"] = "identity" + if media_type: + headers["Content-Type"] = media_type + status = upstream.status + headers = reader_document_browser_headers(target_url, headers, status) + headers = reader_proxy_timing(headers, cache="miss", queued=acquired_at - queued_at, + upstream=headers_at - acquired_at) + declared_length = upstream.headers.get("Content-Length") + length = None + if declared_length is not None: + try: + length = int(declared_length) + except (TypeError, ValueError): + return JSONResponse({"error": "上游阅读文件长度无效"}, status_code=502) + if length < 0 or (max_bytes is not None and length > max_bytes): + return JSONResponse({"error": "阅读文件过大"}, status_code=413) + content_range = upstream.headers.get("Content-Range") + if status == 206: + match = re.fullmatch(r"bytes\s+([0-9]{1,20})-([0-9]{1,20})/([0-9]{1,20})", str(content_range or "")) + if not match: + return JSONResponse({"error": "上游阅读文件范围无效"}, status_code=502) + range_start, range_end, total_length = (int(value) for value in match.groups()) + if (range_start > range_end or range_end >= total_length + or (length is not None and length != range_end - range_start + 1)): + return JSONResponse({"error": "上游阅读文件范围无效"}, status_code=502) + if max_bytes is not None and total_length > max_bytes: + return JSONResponse({"error": "阅读文件过大"}, status_code=413) + elif status == 416: + match = re.fullmatch(r"bytes\s+\*/([0-9]{1,20})", str(content_range or "")) + if match and max_bytes is not None and int(match.group(1)) > max_bytes: + return JSONResponse({"error": "阅读文件过大"}, status_code=413) + if request.method == "HEAD" or status == 304: + return Response(status_code=status, headers=headers) + if status not in (200, 206, 416): + return JSONResponse({"error": f"上游阅读文件加载失败: HTTP {status}"}, status_code=status) + + async def stream_reader_content(): + total = 0 + chunks = [] + cacheable = bool( + range_key and status == 206 and length is not None + and length <= READER_RANGE_CACHE_MAX_ITEM_BYTES + ) + try: + async for chunk in upstream.content.iter_chunked(65536): + total += len(chunk) + if max_bytes is not None and total > max_bytes: + raise ReaderDocumentLimitError("阅读文件过大") + if cacheable: + chunks.append(chunk) + yield chunk + if cacheable and total == length: + payload = b"".join(chunks) + cache = app.state.reader_range_cache + previous = cache.pop(range_key, None) + if previous: + app.state.reader_range_cache_bytes -= len(previous[1]) + cache[range_key] = (time.monotonic(), payload, headers) + app.state.reader_range_cache_bytes += len(payload) + while (app.state.reader_range_cache_bytes > READER_RANGE_CACHE_MAX_BYTES + or len(cache) > READER_RANGE_CACHE_MAX_ENTRIES): + _, (_, evicted, _) = cache.popitem(last=False) + app.state.reader_range_cache_bytes -= len(evicted) + finally: + lease.close() + + return lease.streaming_response(stream_reader_content(), status_code=status, headers=headers) + except Exception as exc: + print(f"阅读代理异常: {exc}") + return JSONResponse({"error": str(exc) or "上游阅读文件加载失败"}, status_code=502) + finally: + if not lease.transferred: + lease.close() + + +@app.api_route("/api/reader-cdn", methods=["GET", "HEAD"]) +async def api_reader_cdn(request: Request, url: str = Query(...)): + """Resolve a public VoiceOfML asset to its short-lived CDN URL. + + The browser can then range-read the CDN directly. Reader clients retain + /api/reader-content as a fallback for networks that cannot reach the CDN. + """ + try: + target_url = validate_reader_source_url(url) + semaphore = app.state.upstream_semaphore + await semaphore.acquire() + lease = UpstreamLease(semaphore) + try: + upstream = lease.response = await open_download_response( + app.state.http_session, + target_url, + aiohttp.ClientTimeout(total=30, connect=10, sock_read=15), + method="HEAD", + url_validator=validate_reader_redirect_url, + auto_decompress=False, + ) + final_url = str(upstream.url) + validate_reader_redirect_url(final_url) + if upstream.status >= 400: + return JSONResponse({"error": f"上游阅读文件加载失败: HTTP {upstream.status}"}, status_code=upstream.status) + return Response(status_code=307, headers={ + "Location": final_url, + "Cache-Control": "no-store", + "Access-Control-Allow-Origin": "*", + }) + finally: + lease.close() + except Exception as exc: + return JSONResponse({"error": str(exc) or "阅读 CDN 解析失败"}, status_code=502) + + +@app.get("/api/download") +async def api_download(file: str = Query(...), link: str = Query(...)): + try: + target_url = validate_voiceofml_source_url(link) + except ValueError: + return JSONResponse({"error": "只允许 HuggingFace 域名"}, status_code=403) + session = app.state.http_session + semaphore = app.state.upstream_semaphore + await semaphore.acquire() + with UpstreamLease(semaphore) as lease: + try: + upstream = lease.response = await open_download_response( + session, + target_url, + aiohttp.ClientTimeout(total=120, connect=15, sock_read=30), + url_validator=validate_voiceofml_redirect_url, + ) + except Exception as e: + print(f"下载代理异常: {e}") + return JSONResponse({"error": str(e) or "上游下载失败"}, status_code=502) + if upstream.status != 200: + return JSONResponse({"error": f"上游下载失败: HTTP {upstream.status}"}, status_code=502) + encoded_filename = quote(sanitize_archive_name(file), safe="") + return lease.streaming_response( + lease.chunks("下载代理异常"), + media_type="application/octet-stream", + headers={ + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}", + "Cache-Control": "no-cache", + }, + ) + + +@app.get("/api/download/check") +async def api_download_check(link: str = Query(...)): + target_url = link + try: + target_url = validate_voiceofml_source_url(target_url) + except ValueError as e: + return JSONResponse({"error": str(e)}, status_code=403) + session = app.state.http_session + semaphore = app.state.upstream_semaphore + await semaphore.acquire() + upstream = None + try: + upstream = await open_download_response( + session, + target_url, + aiohttp.ClientTimeout(total=30, connect=10, sock_read=10), + method="HEAD", + url_validator=validate_voiceofml_redirect_url, + ) + if upstream.status not in (200, 206): + return JSONResponse({"error": f"上游下载失败: HTTP {upstream.status}"}, status_code=502) + return JSONResponse({"ok": True}) + except Exception as e: + print(f"下载检查异常: {e}") + return JSONResponse({"error": str(e) or "上游下载失败"}, status_code=502) + finally: + if upstream is not None: + upstream.release() + semaphore.release() +TXT_DIR.mkdir(parents=True, exist_ok=True) + +def resolve_safe_txt_path(raw_path: str) -> Path: + decoded_path = unquote(raw_path).replace("\\", "/").strip("/") + normalized = posixpath.normpath(decoded_path) + if normalized in ("", ".") or normalized.startswith("../") or normalized == "..": + raise ValueError("invalid txt path") + target = (TXT_DIR / normalized).resolve() + base = TXT_DIR.resolve() + if target != base and base not in target.parents: + raise ValueError("invalid txt path") + return target + +@app.get("/txt/{txt_path:path}") +async def serve_txt_proxy(txt_path: str): + try: + local_path = resolve_safe_txt_path(txt_path) + except ValueError: + return JSONResponse({"error": "invalid txt path"}, status_code=400) + if local_path.exists() and local_path.is_file(): + return FileResponse(local_path, media_type="text/plain; charset=utf-8") + remote_path = unquote(txt_path).replace("\\", "/").strip("/") + remote_url = TXT_SPACE_RAW_BASE + "/" + quote(remote_path, safe="/") + session = app.state.http_session + with UpstreamLease() as lease: + try: + upstream = lease.response = await open_download_response( + session, + remote_url, + aiohttp.ClientTimeout(total=60, connect=10, sock_read=20), + ) + except Exception as e: + print(f"txt 代理异常: {e}") + return JSONResponse({"error": str(e) or "txt 读取失败"}, status_code=502) + if upstream.status != 200: + status = upstream.status + return JSONResponse({"error": f"txt 不存在: HTTP {status}"}, status_code=404 if status == 404 else 502) + return lease.streaming_response(lease.chunks("txt 代理异常"), media_type="text/plain; charset=utf-8") + +HASHED_ASSET_RE = re.compile(r"\.[0-9a-f]{12}\.(?:js|css|mjs)$") + + +class HashedStaticFiles(StaticFiles): + async def get_response(self, path: str, scope): + response = await super().get_response(path, scope) + if response.status_code == 200 and HASHED_ASSET_RE.search(path): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + +app.mount("/static", HashedStaticFiles(directory="static", html=True), name="static") +app.mount("/icons", StaticFiles(directory="static/icons"), name="icons") +@app.get("/manifest.json") + +def serve_manifest(): + p = Path("static/manifest.json") + if p.exists(): + return JSONResponse(json.loads(p.read_text(encoding="utf-8"))) + return JSONResponse({"error": "manifest not found"}, status_code=404) +@app.get("/sw.js") + +def serve_sw(): + p = Path("static/sw.js") + if p.exists(): + return PlainTextResponse(p.read_text(encoding="utf-8"), media_type="application/javascript") + return PlainTextResponse("", status_code=404) + +@app.get("/data/initial/global.json") +def serve_initial_global(): + return JSONResponse(cached_payload(("initial_global", _data_generation), lambda: initial_payload_global or build_initial_payload(None))) + +@app.get("/data/initial/repos/{repo_name}.json") +def serve_initial_repo(repo_name: str): + full_repo = f"VoiceOfML/{repo_name}" + payload = initial_payload_by_repo.get(full_repo) + if not payload: + return JSONResponse({"error": f"仓库 {full_repo} 不存在"}, status_code=404) + return JSONResponse(cached_payload(("initial_repo", _data_generation, full_repo), lambda: payload)) + +@app.get("/data/initial/manifest.json") +def serve_initial_manifest(): + def build(): + urls = ["/data/initial/global.json"] + for repo in sorted(initial_payload_by_repo): + urls.append(f"/data/initial/repos/{quote(repo.split('/')[-1], safe='')}.json") + return {"urls": urls} + return JSONResponse(cached_payload(("initial_manifest", _data_generation), build)) + +def read_sidebar_json(path: Path): + data = load_sidebar_payload(path) + if data is None: + return JSONResponse({"error": "sidebar data not found"}, status_code=404) + return JSONResponse( + cached_payload(("sidebar_file", _data_generation, str(path)), lambda: data), + headers=METADATA_CACHE_HEADERS, + ) + +@app.get("/data/sidebar/global.json") +def serve_sidebar_global(): + return read_sidebar_json(SIDEBAR_DATA_PATH / "global.json") + +@app.get("/data/sidebar/repos/{repo_name}.json") +def serve_sidebar_repo(repo_name: str): + full_repo = f"VoiceOfML/{repo_name}" + if full_repo not in repo_counts: + return JSONResponse({"error": f"仓库 {full_repo} 不存在"}, status_code=404) + return read_sidebar_json(SIDEBAR_DATA_PATH / "repos" / f"{quote(repo_name, safe='')}.json") + +@app.get("/data/sidebar/manifest.json") +def serve_sidebar_manifest(): + return read_sidebar_json(SIDEBAR_DATA_PATH / "manifest.json") + +@app.get("/") +async def serve_root(): + html = injected_html_cache.get(None) + if html is not None: + return HTMLResponse(html) + index_path = Path("static/index.html") + if index_path.exists(): + return HTMLResponse(inject_initial_payload(index_path.read_text(encoding="utf-8"), None)) + return PlainTextResponse("Frontend shell unavailable", status_code=503) + +@app.get("/{rest_of_path:path}") + +async def serve_spa(rest_of_path: str): + index_path = Path("static/index.html") + if index_path.exists(): + repo_short = rest_of_path.strip("/").split("/")[0] if rest_of_path.strip("/") else None + if repo_short and f"VoiceOfML/{repo_short}" not in repo_counts: + return PlainTextResponse("Repository not found", status_code=404) + cache_key = f"VoiceOfML/{repo_short}" if repo_short else None + html = injected_html_cache.get(cache_key) + if html is not None: + return HTMLResponse(html) + return HTMLResponse(inject_initial_payload(index_path.read_text(encoding="utf-8"), repo_short)) + return PlainTextResponse("Frontend shell unavailable", status_code=503) + +def reader_proxy_timing(headers: dict, *, cache: str, queued: float = 0, upstream: float = 0) -> dict: + return {**headers, "Server-Timing": ( + f'reader_cache;desc="{cache}", reader_queue;dur={queued * 1000:.2f}, ' + f'reader_headers;dur={upstream * 1000:.2f}' + )} + + +def reader_cached_subrange(range_key): + requested = re.fullmatch(r"bytes=([0-9]{1,20})-([0-9]{1,20})", range_key[1]) + if requested is None: + return None + start, end = (int(value) for value in requested.groups()) + if start > end or end - start + 1 > READER_RANGE_CACHE_MAX_ITEM_BYTES: + return None + cache = app.state.reader_range_cache + now = time.monotonic() + for key in reversed(list(cache)): + cached_at, payload, headers = cache[key] + if now - cached_at >= READER_RANGE_CACHE_TTL_SECONDS: + cache.pop(key) + app.state.reader_range_cache_bytes -= len(payload) + continue + if key[0] != range_key[0] or key[2:] != range_key[2:]: + continue + match = re.fullmatch(r"bytes ([0-9]+)-([0-9]+)/([0-9]+)", headers.get("Content-Range", "")) + if match is None: + continue + cached_start, cached_end, total = (int(value) for value in match.groups()) + final_end = min(end, total - 1) + if cached_start <= start <= final_end <= cached_end and len(payload) == cached_end - cached_start + 1: + cache.move_to_end(key) + body = payload[start - cached_start:final_end - cached_start + 1] + return body, {**headers, "Content-Range": f"bytes {start}-{final_end}/{total}", + "Content-Length": str(len(body))} + return None + + +async def shutdown_reader_range_flights(): + owners = getattr(app.state, "reader_range_flight_owners", set()) + await asyncio.gather(*(flight.close() for flight in list(owners)), return_exceptions=True) + owners.clear() + getattr(app.state, "reader_range_flights", {}).clear() + + +async def shared_reader_range_response(request, target_url, range_key, options): + if not hasattr(app.state, "reader_range_flights"): + app.state.reader_range_flights = {} + app.state.reader_range_flight_owners = set() + flights = app.state.reader_range_flights + owners = app.state.reader_range_flight_owners + policy = options["validation_scope"] if options["validation_scope"] is not None else options["url_validator"] + key = (range_key, policy, options["cache_control"]) + flight = flights.get(key) + joined = flight is not None + if flight is None: + if len(flights) >= READER_RANGE_MAX_FLIGHTS: + return await _stream_reader_document(request, target_url, _share_range=False, **options) + + def finished(owner): + if flights.get(key) is owner: + flights.pop(key, None) + if owner.done: + owners.discard(owner) + + flight = SharedRangeFlight( + lambda: _stream_reader_document(request, target_url, _share_range=False, **options), + READER_RANGE_CACHE_MAX_ITEM_BYTES, finished) + flights[key] = flight + owners.add(flight) + subscription = flight.subscribe() + shared_at = time.monotonic() + transferred = False + try: + if not await asyncio.shield(flight.ready): + response = flight.take_response() + if response is not None: + return response + return await _stream_reader_document(request, target_url, _share_range=False, **options) + headers = dict(flight.headers) + if joined: + headers["server-timing"] += f', reader_shared;dur={(time.monotonic() - shared_at) * 1000:.2f};desc="joined"' + response = OwnedStreamingResponse(subscription.stream(), cleanup=subscription.release, + status_code=206, headers=headers) + transferred = True + return response + finally: + if not transferred: + await subscription.release() diff --git a/data_formats.py b/backend/data_formats.py similarity index 100% rename from data_formats.py rename to backend/data_formats.py diff --git a/search_index_native.c b/backend/native/search_index_native.c similarity index 100% rename from search_index_native.c rename to backend/native/search_index_native.c diff --git a/proxy_streams.py b/backend/proxy_streams.py similarity index 100% rename from proxy_streams.py rename to backend/proxy_streams.py diff --git a/reader_assets.py b/backend/reader_assets.py similarity index 99% rename from reader_assets.py rename to backend/reader_assets.py index 2653dfa7937f1943b09e17df23e3e10c88dd1853..e299759850fbfadd3a6344a150439b99797e72f2 100644 --- a/reader_assets.py +++ b/backend/reader_assets.py @@ -6,7 +6,7 @@ import json import posixpath import re -from upstream_http import READER_ASSET_PATH_RE +from backend.upstream_http import READER_ASSET_PATH_RE READER_ASSET_FILENAMES = { diff --git a/reader_v3_catalog.py b/backend/reader_v3_catalog.py similarity index 98% rename from reader_v3_catalog.py rename to backend/reader_v3_catalog.py index ed64b62aaf082f506fe27efab997c3fb4a05f318..ede6681462b8f0933eb8943bcbd504ae9e58b7d1 100644 --- a/reader_v3_catalog.py +++ b/backend/reader_v3_catalog.py @@ -22,7 +22,7 @@ def catalog_path(generation): def decode_catalog(pointer, raw): - from reader_assets import READER_BUCKET_PATH_RE + from backend.reader_assets import READER_BUCKET_PATH_RE if pointer.get("version") != 1 or pointer.get("kind") != "reader-reading-pointer": raise ValueError("invalid v3 pointer") generation = pointer.get("generation") diff --git a/record_metadata.py b/backend/record_metadata.py similarity index 100% rename from record_metadata.py rename to backend/record_metadata.py diff --git a/search_execution.py b/backend/search_execution.py similarity index 100% rename from search_execution.py rename to backend/search_execution.py diff --git a/search_indexes.py b/backend/search_indexes.py similarity index 100% rename from search_indexes.py rename to backend/search_indexes.py diff --git a/upstream_http.py b/backend/upstream_http.py similarity index 100% rename from upstream_http.py rename to backend/upstream_http.py diff --git a/scripts/benchmark_reader_open.py b/scripts/benchmarks/benchmark_reader_open.py similarity index 100% rename from scripts/benchmark_reader_open.py rename to scripts/benchmarks/benchmark_reader_open.py diff --git a/scripts/benchmark_reader_stages.py b/scripts/benchmarks/benchmark_reader_stages.py similarity index 100% rename from scripts/benchmark_reader_stages.py rename to scripts/benchmarks/benchmark_reader_stages.py diff --git a/scripts/reader-opening-media-samples.json b/scripts/benchmarks/reader-opening-media-samples.json similarity index 100% rename from scripts/reader-opening-media-samples.json rename to scripts/benchmarks/reader-opening-media-samples.json diff --git a/scripts/reader-opening-samples.json b/scripts/benchmarks/reader-opening-samples.json similarity index 100% rename from scripts/reader-opening-samples.json rename to scripts/benchmarks/reader-opening-samples.json diff --git a/scripts/build_search_index_native.py b/scripts/build/build_search_index_native.py similarity index 89% rename from scripts/build_search_index_native.py rename to scripts/build/build_search_index_native.py index 3977716be8bd80854da07b177ec668a84dbdee00..009edfc1bc396f1351e23344544bf8beae47ae8f 100644 --- a/scripts/build_search_index_native.py +++ b/scripts/build/build_search_index_native.py @@ -11,7 +11,7 @@ parser.add_argument("--output-dir", required=True) args = parser.parse_args() output = Path(args.output_dir) output.mkdir(parents=True, exist_ok=True) -source = Path(__file__).resolve().parents[1] / "search_index_native.c" +source = Path(__file__).resolve().parents[2] / "backend/native/search_index_native.c" target = output / ("_search_index_native" + sysconfig.get_config_var("EXT_SUFFIX")) compiler = shlex.split(os.environ.get("CC") or sysconfig.get_config_var("CC") or "cc") subprocess.run([*compiler, "-O3", "-shared", "-fPIC", "-Wall", "-Wextra", diff --git a/scripts/build_static_assets.mjs b/scripts/build/build_static_assets.mjs similarity index 80% rename from scripts/build_static_assets.mjs rename to scripts/build/build_static_assets.mjs index 96ee056f3a4d98255e65e306b776275f72bf5e17..0baf4cc88386940fa1769eb62d96d0e8ddd5af69 100644 --- a/scripts/build_static_assets.mjs +++ b/scripts/build/build_static_assets.mjs @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { renameSync, readFileSync, writeFileSync } from "node:fs"; -import { extname, join } from "node:path"; +import { dirname, extname, join, relative } from "node:path"; import { createRequire } from "node:module"; import { stripBundledScripts } from "./compose_app.mjs"; const resources = createRequire(import.meta.url)(join(process.cwd(), "static/reader-resources.js")); @@ -8,7 +8,15 @@ const resources = createRequire(import.meta.url)(join(process.cwd(), "static/rea const outputDir = process.env.STATIC_OUTPUT_DIR || "/out"; const replacements = new Map(); const currentVendorAssets = new Set(Object.values(resources.vendors).map(vendor => "/static/" + vendor.path)); -function rewrite(text) { +function rewrite(text, filename) { + text = text.replace(/(["'])(\.\.?\/[^"'\s]+)\1/g, (match, quote, url) => { + const target = join(dirname(filename), url); + const versioned = replacements.get(`/static/${target}`); + if (!versioned) return match; + let pinned = relative(dirname(filename), versioned.slice("/static/".length)); + if (!pinned.startsWith(".")) pinned = "./" + pinned; + return quote + pinned + quote; + }); text = text.replace(/VoiceOfMLReaderResources\.engineAssets\(([\w$]+),\s*"(\/static\/)"\)/g, (_, extension, base) => "(" + JSON.stringify(Object.fromEntries(["pdf", "epub", "mobi", "azw", "azw3", "fb2", "fbz", "docx", "md", "markdown", "html", "htm"].map(ext => [ext, resources.engineAssets(ext, base)]))) + `[${extension}]||[])`); text = text.replace(/VoiceOfMLReaderResources\.(shellAssets|runtimePaths)\("(\/static\/)"\)/g, @@ -23,7 +31,7 @@ for (const filename of resources.buildFiles("hf")) { let text = readFileSync(source, "utf8"); for (const match of text.matchAll(/(?:\/static\/|\.\/)vendor\/([A-Za-z0-9._-]+\.[0-9a-f]{12}\.(?:js|mjs|css))/g)) currentVendorAssets.add("/static/vendor/" + match[1]); - text = rewrite(text); + text = rewrite(text, filename); writeFileSync(source, text); } const content = readFileSync(source); @@ -40,7 +48,7 @@ for (const filename of ["index.html", "reader.html", "sw.js"]) { const path = join(outputDir, filename); let content = readFileSync(path, "utf8"); if (filename === "index.html") content = stripBundledScripts(content); - content = rewrite(content); + content = rewrite(content, filename); if (filename === "sw.js") content = content.replace(/CURRENT_HASHED_ASSETS\s*=\s*\[\]/, "CURRENT_HASHED_ASSETS=" + JSON.stringify([...replacements.values()].filter(url => url.startsWith("/")).concat([...currentVendorAssets]))); writeFileSync(path, content); diff --git a/scripts/check_reader_release.mjs b/scripts/build/check_reader_release.mjs similarity index 79% rename from scripts/check_reader_release.mjs rename to scripts/build/check_reader_release.mjs index 94daaad80544d3f640b266d4d27e29de1add9ffb..9aa6eebab6c58003e2ac01968c8965a2f0cc40d4 100644 --- a/scripts/check_reader_release.mjs +++ b/scripts/build/check_reader_release.mjs @@ -39,10 +39,11 @@ function staticRoot(root, artifact) { } function checkBehavior(root) { - const reader = fs.readFileSync(path.join(root, "static/reader.js"), "utf8"); - for (const [, dependency] of reader.matchAll(/(?:\/static\/)([^/"']+\.(?:js|mjs))/g)) { + const reader = ["reader.js", ...resources.readerComponentFiles].map(file => + fs.readFileSync(path.join(root, "static", file), "utf8")).join("\n").replace(/\breaderContext\./g, ""); + for (const [, dependency] of reader.matchAll(/(?:\/static\/)([^"'\s]+\.(?:js|mjs))/g)) { assert( - resources.readerFiles.includes(dependency), + required.includes(dependency), `Reader dependency missing from inventory: ${dependency}` ); } @@ -118,11 +119,12 @@ function checkAssets(output, hashed = false) { function currentAsset(output, filename) { if (!resources.buildFiles("github").includes(filename)) return filename; assert(!fs.existsSync(path.join(output, filename)), `obsolete asset alias: ${filename}`); - const extension = path.extname(filename), stem = filename.slice(0, -extension.length); - const matches = fs.readdirSync(output).filter(file => file.startsWith(stem + ".") && + const directory = path.dirname(filename), basename = path.basename(filename); + const extension = path.extname(basename), stem = basename.slice(0, -extension.length); + const matches = fs.readdirSync(path.join(output, directory)).filter(file => file.startsWith(stem + ".") && file.endsWith(extension) && /^[0-9a-f]{12}$/.test(file.slice(stem.length + 1, -extension.length))); assert(matches.length === 1, `expected one current asset: ${filename}`); - return matches[0]; + return path.join(directory, matches[0]); } function checkProject(root, artifact) { @@ -138,7 +140,7 @@ function checkProject(root, artifact) { const output = staticRoot(root, artifact); checkAssets(output, !!artifact); if (artifact) { - const project = fs.existsSync(path.join(root, "index.html")) ? "github" : "hf"; + const project = args.project || (fs.existsSync(path.join(root, "Dockerfile")) ? "hf" : "github"); for (const file of resources.buildFiles(project)) currentAsset(output, file); const shellPath = fs.existsSync(path.join(output, "index.html")) ? path.join(output, "index.html") : path.join(output, "../index.html"); @@ -167,24 +169,30 @@ function checkProject(root, artifact) { function crossCheck(githubRoot, hfRoot) { const exact = [ "reader-resources.js", - "reader-store.js", - "reader-pdf-text.js", - "reader-pdf-book-search-worker.mjs", - "reader-pdf-book-search.mjs", - "search-session.js", - "download-controller.js", - "reader-navigation.js", + "reader/core/reader-store.js", + "reader/pdf/reader-pdf-text.js", + "reader/search/workers/reader-pdf-book-search-worker.mjs", + "reader/search/reader-pdf-book-search.mjs", + "search/search-session.js", + "search/download-controller.js", + "search/reader-navigation.js", "reader.css", - "reader-request-manager.js", - "reader-chapter-repository.js", - "reader-scroll-anchor.js", - "reader-section-virtualizer.js", - "reader-runtime.js", - "reader-format-adapters.js", - "reader-security.js", - "reader-search-controller.mjs", - "reader-dom-search.mjs", - "pdf-worker-wrapper.mjs" + "reader/core/reader-request-manager.js", + "reader/formats/reader-chapter-repository.js", + "reader/ui/reader-scroll-anchor.js", + "reader/formats/reader-section-virtualizer.js", + "reader/core/reader-runtime.js", + "reader/formats/reader-format-adapters.js", + "reader/core/reader-security.js", + "reader/search/reader-search-controller.mjs", + "reader/search/reader-dom-search.mjs", + "reader/formats/reader-image-loader.mjs", + "reader/reader-components.mjs", + "reader/ui/reader-document-styles.mjs", + "reader/core/reader-diagnostics.mjs", "reader/core/reader-lifecycle.mjs", "reader/ui/reader-bookmarks.mjs", + "reader/formats/reader-media-format.mjs", "reader/core/reader-document-loader.mjs", "reader/formats/reader-html-format.mjs", + "reader/formats/reader-text-format.mjs", "reader/formats/reader-chapter-format.mjs", "reader/formats/reader-docx-format.mjs", + "reader/pdf/pdf-worker-wrapper.mjs" ]; for (const file of exact) { assert( @@ -194,8 +202,8 @@ function crossCheck(githubRoot, hfRoot) { } for (const file of ["compose_app.mjs", "copy_reader_vendor.mjs", "check_reader_release.mjs"]) { assert( - hash(path.join(githubRoot, "scripts", file)) === hash(path.join(hfRoot, "scripts", file)), - `cross-project drift: scripts/${file}` + hash(path.join(githubRoot, "scripts/build", file)) === hash(path.join(hfRoot, "scripts/build", file)), + `cross-project drift: scripts/build/${file}` ); } for (const root of [githubRoot, hfRoot]) checkBehavior(root); diff --git a/scripts/compose_app.mjs b/scripts/build/compose_app.mjs similarity index 79% rename from scripts/compose_app.mjs rename to scripts/build/compose_app.mjs index 354ccb7650216ab1d7cbdcfbbf6fe237204d57b4..b638bb7975de0957f2671891656345baf6294739 100644 --- a/scripts/compose_app.mjs +++ b/scripts/build/compose_app.mjs @@ -5,12 +5,12 @@ import { pathToFileURL } from "node:url"; // One ordered dependency list owns the production app and its shell scripts. // Keep classic-script globals for the host; each dependency owns its own scope. export const APP_DEPENDENCIES = Object.freeze([ - "reader-resources.js", "reader-contract.js", "reader-navigation.js", - "search-session.js", "download-controller.js" + "reader-resources.js", "reader/core/reader-contract.js", "search/reader-navigation.js", + "search/search-session.js", "search/download-controller.js" ]); export function stripBundledScripts(html) { - return html.replace(/\s*<\/script>\s*/g, + return html.replace(/\s*<\/script>\s*/g, (tag, filename) => APP_DEPENDENCIES.includes(filename) ? "" : tag); } diff --git a/scripts/copy_reader_vendor.mjs b/scripts/build/copy_reader_vendor.mjs similarity index 100% rename from scripts/copy_reader_vendor.mjs rename to scripts/build/copy_reader_vendor.mjs diff --git a/static/index.html b/static/index.html index 2123a8a61e9e0220ba2dec7a3cea0cddce73dfca..6b383a17829b1d8608e076ba818f1800c15995b1 100644 --- a/static/index.html +++ b/static/index.html @@ -287,10 +287,10 @@ if ("serviceWorker" in navigator) { - - - - + + + + diff --git a/static/reader-resources.js b/static/reader-resources.js index a51ec7351799f461387d9de65b4e2b5f0a4b06e7..c60a16938d82ba54adf682221a09bb43bcf0fe34 100644 --- a/static/reader-resources.js +++ b/static/reader-resources.js @@ -85,41 +85,55 @@ } }; for (const vendor of Object.values(foliateVendors)) Object.freeze(vendor); + const readerComponentFiles = Object.freeze([ + "reader/core/reader-diagnostics.mjs", "reader/core/reader-lifecycle.mjs", "reader/core/reader-source.mjs", "reader/ui/reader-ui.mjs", + "reader/ui/reader-toc.mjs", "reader/search/reader-search-backends.mjs", "reader/pdf/reader-pdf-scheduler.mjs", "reader/ui/reader-navigation.mjs", + "reader/formats/reader-foliate-format.mjs", "reader/ui/reader-progress.mjs", "reader/ui/reader-bookmarks.mjs", "reader/formats/reader-media-format.mjs", + "reader/core/reader-document-loader.mjs", "reader/pdf/reader-pdf-page-window.mjs", "reader/pdf/reader-pdf-text-runtime.mjs", + "reader/formats/reader-html-format.mjs", "reader/pdf/reader-pdf-renderer.mjs", "reader/formats/reader-text-format.mjs", + "reader/formats/reader-chapter-format.mjs", "reader/formats/reader-docx-format.mjs", "reader/formats/reader-format-controller.mjs", + "reader/pdf/reader-pdf-engine.mjs", "reader/pdf/reader-pdf-search.mjs", "reader/core/reader-bindings.mjs", "reader/ui/reader-document-styles.mjs" + ]); const readerFiles = Object.freeze([ "reader-resources.js", - "reader-engine-preload.js", - "reader-chapter-search-worker.mjs", - "reader-chapter-search.mjs", - "reader-contract.js", - "reader-navigation.js", - "reader-store.js", - "reader-request-manager.js", - "reader-chapter-repository.js", - "reader-scroll-anchor.js", - "reader-section-virtualizer.js", - "reader-runtime.js", - "reader-format-adapters.js", - "reader-security.js", - "reader-search-controller.mjs", - "reader-dom-search.mjs", - "reader-pdf-text.js", - "reader-v3.mjs", - "reader-book-text.js", - "reader-pdf-book-search-worker.mjs", - "reader-pdf-book-search.mjs", - "pdf-worker-wrapper.mjs", - "reader-pdf-network.mjs", - "reader-pdf-text-store.mjs", + "reader/pdf/reader-engine-preload.js", + "reader/search/workers/reader-chapter-search-worker.mjs", + "reader/search/reader-chapter-search.mjs", + "reader/core/reader-contract.js", + "search/reader-navigation.js", + "reader/core/reader-store.js", + "reader/core/reader-request-manager.js", + "reader/formats/reader-chapter-repository.js", + "reader/ui/reader-scroll-anchor.js", + "reader/formats/reader-section-virtualizer.js", + "reader/core/reader-runtime.js", + "reader/formats/reader-format-adapters.js", + "reader/core/reader-security.js", + "reader/search/reader-search-controller.mjs", + "reader/search/reader-dom-search.mjs", + "reader/formats/reader-image-loader.mjs", + "reader/pdf/reader-pdf-text.js", + "reader/pdf/reader-v3.mjs", + "reader/search/reader-book-text.js", + "reader/search/workers/reader-pdf-book-search-worker.mjs", + "reader/search/reader-pdf-book-search.mjs", + "reader/pdf/pdf-worker-wrapper.mjs", + "reader/pdf/reader-pdf-network.mjs", + "reader/pdf/reader-pdf-text-store.mjs", "reader.css", + ...readerComponentFiles, + "reader/reader-components.mjs", "reader.js" ]); const lazyFiles = new Set([ - "reader-pdf-text-store.mjs", - "reader-chapter-search-worker.mjs", - "reader-chapter-search.mjs", - "reader-pdf-book-search-worker.mjs", - "reader-pdf-book-search.mjs", - "pdf-worker-wrapper.mjs" + ...readerComponentFiles, + "reader/reader-components.mjs", + "reader/pdf/reader-pdf-text-store.mjs", + "reader/search/workers/reader-chapter-search-worker.mjs", + "reader/search/reader-chapter-search.mjs", + "reader/search/workers/reader-pdf-book-search-worker.mjs", + "reader/search/reader-pdf-book-search.mjs", + "reader/pdf/pdf-worker-wrapper.mjs" ]); const vendorUrl = (name, base) => base + vendors[name].path; const runtimePaths = (base) => readerFiles.map((file) => base + file); @@ -129,7 +143,7 @@ if (extension === "pdf") return [ vendorUrl("pdf", base), - base + "pdf-worker-wrapper.mjs", + base + "reader/pdf/pdf-worker-wrapper.mjs", vendorUrl("pdfWorker", base) ]; if (["epub", "mobi", "azw", "azw3", "fb2", "fbz"].includes(extension)) @@ -142,24 +156,33 @@ function buildFiles(project) { return [ ...readerFiles, - "search-session.js", - "download-controller.js", + "search/search-session.js", + "search/download-controller.js", "style.css", - ...(project === "github" ? ["index-worker.js"] : []), + ...(project === "github" ? ["search/index-worker.js"] : []), "app.js" ]; } + function bundleArguments(filename) { + if (!["reader.js", "reader/reader-components.mjs"].includes(filename)) return []; + const external = readerFiles.filter(file => !readerComponentFiles.includes(file) && + !["reader.js", "reader/reader-components.mjs"].includes(file) && /\.(?:js|mjs)$/.test(file)); + return ["--bundle", "--format=esm", "--external:/static/*", "--external:/search/static/*", + ...external.map(file => "--external:./" + file)]; + } const resources = Object.freeze({ vendors: Object.freeze(vendors), foliateVendors: Object.freeze(foliateVendors), foliateRevision: "78914aef4466eb960965702401634c2cb348e9b1", pdfArchive, readerFiles, + readerComponentFiles, vendorUrl, runtimePaths, shellAssets, engineAssets, - buildFiles + buildFiles, + bundleArguments }); if (typeof module !== "undefined" && module.exports) module.exports = resources; else root.VoiceOfMLReaderResources = resources; diff --git a/static/reader.html b/static/reader.html index c0ae69f9c48bf3fcf95ef2ab8c240db0d6793788..7d05121dfca8fd0476734d36808e22a8fab7d65a 100644 --- a/static/reader.html +++ b/static/reader.html @@ -51,11 +51,11 @@ - + - - - + + +
diff --git a/static/reader.js b/static/reader.js index ddc6d3197d7b612785bfa4bfce9be031df0375ab..3c7fe9c202e96d30d24ce1b6ab28a07aaeba7daa 100644 --- a/static/reader.js +++ b/static/reader.js @@ -1,37 +1,695 @@ -import { createReaderDomSearch, fullSearchEscape, fullSearchSnippet, fullSearchPageOffset } from "./reader-dom-search.mjs"; -import { createReaderSearchController } from "./reader-search-controller.mjs"; +import { createReaderDomSearch, fullSearchEscape, fullSearchSnippet, fullSearchPageOffset } from "./reader/search/reader-dom-search.mjs"; +import { createReaderSearchController } from "./reader/search/reader-search-controller.mjs"; +import { createReaderImageLoader } from "./reader/formats/reader-image-loader.mjs"; import "/static/reader-resources.js"; -import "/static/reader-contract.js"; -import "/static/reader-request-manager.js"; -import "/static/reader-chapter-repository.js"; -import "/static/reader-scroll-anchor.js"; -import "/static/reader-section-virtualizer.js"; -import "/static/reader-runtime.js"; -import "/static/reader-format-adapters.js"; -import { populatePdfTextLayer, populateOcrTextLayer, pdfTextContent, pdfSearchText, normalizePdfSearchText, pdfSourceOffset } from "/static/reader-pdf-text.js"; -import { paintTextHit, hitBoxes } from "/static/reader-book-text.js"; -import { createPdfFetchPolicy } from "./reader-pdf-network.mjs"; -import { createV3Repository, validateResource, validateReadingManifest, validatePageMap } from "./reader-v3.mjs"; -import { populateIndependentTextLayer, independentTextCaret } from "./reader-pdf-text.js"; +import "/static/reader/core/reader-contract.js"; +import "/static/reader/core/reader-request-manager.js"; +import "/static/reader/formats/reader-chapter-repository.js"; +import "/static/reader/ui/reader-scroll-anchor.js"; +import "/static/reader/formats/reader-section-virtualizer.js"; +import "/static/reader/core/reader-runtime.js"; +import "/static/reader/formats/reader-format-adapters.js"; +import { populatePdfTextLayer, populateOcrTextLayer, pdfTextContent, pdfSearchText, normalizePdfSearchText, pdfSourceOffset } from "/static/reader/pdf/reader-pdf-text.js"; +import { paintTextHit, hitBoxes } from "/static/reader/search/reader-book-text.js"; +import { createPdfFetchPolicy } from "./reader/pdf/reader-pdf-network.mjs"; +import { createV3Repository, validateResource, validateReadingManifest, validatePageMap } from "./reader/pdf/reader-v3.mjs"; +import { populateIndependentTextLayer, independentTextCaret } from "./reader/pdf/reader-pdf-text.js"; +import { + EPUB_HTML_TAGS, + EPUB_HTML_VOID_TAGS, + HTML_FRAME_STYLE, + DISCOURSE_POST_SELECTOR, + DISCOURSE_TITLE_SELECTOR, + DISCOURSE_HTML_STYLE, + SPREADSHEET_HTML_STYLE, + createReaderBindings, + createReaderDiagnostics, + createReaderLifecycle, + createReaderSource, + createReaderUi, + createReaderToc, + createReaderSearchBackends, + createReaderPdfScheduler, + createReaderNavigation, + createReaderFoliateFormat, + createReaderProgress, + createReaderBookmarks, + createReaderMediaFormat, + createReaderDocumentLoader, + createReaderPdfPageWindow, + createReaderPdfTextRuntime, + createReaderHtmlFormat, + createReaderPdfRenderer, + createReaderTextFormat, + createReaderChapterFormat, + createReaderDocxFormat, + createReaderFormatController, + createReaderPdfEngine, + createReaderPdfSearch +} from "./reader/reader-components.mjs"; + +const readerContext = { + get DISCOURSE_HTML_STYLE() { return DISCOURSE_HTML_STYLE; }, + get DISCOURSE_POST_SELECTOR() { return DISCOURSE_POST_SELECTOR; }, + get DISCOURSE_TITLE_SELECTOR() { return DISCOURSE_TITLE_SELECTOR; }, + get DOCX_PREVIEW_URL() { return DOCX_PREVIEW_URL; }, + get EPUB_HTML_TAGS() { return EPUB_HTML_TAGS; }, + get EPUB_HTML_VOID_TAGS() { return EPUB_HTML_VOID_TAGS; }, + get FOLIATE_LOADED_SECTION_LIMIT() { return FOLIATE_LOADED_SECTION_LIMIT; }, + get FOLIATE_PREFETCH_MARGIN() { return FOLIATE_PREFETCH_MARGIN; }, + get HTML_FRAME_STYLE() { return HTML_FRAME_STYLE; }, + get JSZIP_URL() { return JSZIP_URL; }, + get MARKED_URL() { return MARKED_URL; }, + get PDFJS_CMAP_URL() { return PDFJS_CMAP_URL; }, + get PDFJS_STANDARD_FONT_URL() { return PDFJS_STANDARD_FONT_URL; }, + get PDFJS_URL() { return PDFJS_URL; }, + get PDFJS_WASM_URL() { return PDFJS_WASM_URL; }, + get PDFJS_WORKER_URL() { return PDFJS_WORKER_URL; }, + get PDF_BOOK_SEARCH_PREFETCH_DELAY() { return PDF_BOOK_SEARCH_PREFETCH_DELAY; }, + get PDF_BOOK_SEARCH_PREFETCH_MAX_BYTES() { return PDF_BOOK_SEARCH_PREFETCH_MAX_BYTES; }, + get PDF_MANIFEST_PREFETCH_CONCURRENCY() { return PDF_MANIFEST_PREFETCH_CONCURRENCY; }, + get PDF_MANIFEST_PREFETCH_LIMIT() { return PDF_MANIFEST_PREFETCH_LIMIT; }, + get PDF_MANIFEST_PREFETCH_MAX_PAGES() { return PDF_MANIFEST_PREFETCH_MAX_PAGES; }, + get PDF_OCR_PAGE_CACHE_BYTES() { return PDF_OCR_PAGE_CACHE_BYTES; }, + get PDF_PREFETCH_MARGIN() { return PDF_PREFETCH_MARGIN; }, + get PDF_SEARCH_TEXT_CACHE_BYTES() { return PDF_SEARCH_TEXT_CACHE_BYTES; }, + get PDF_TEXT_CONTENT_CACHE_BYTES() { return PDF_TEXT_CONTENT_CACHE_BYTES; }, + get PURIFY_URL() { return PURIFY_URL; }, + get READER_PROXY_TIMEOUT_MS() { return READER_PROXY_TIMEOUT_MS; }, + get RUFFLE_URL() { return RUFFLE_URL; }, + get SPREADSHEET_HTML_STYLE() { return SPREADSHEET_HTML_STYLE; }, + get THEME_MOON_ICON() { return THEME_MOON_ICON; }, + get THEME_SUN_ICON() { return THEME_SUN_ICON; }, + get TOC_VIRTUALIZATION_THRESHOLD() { return TOC_VIRTUALIZATION_THRESHOLD; }, + get TOC_VIRTUAL_ROW_HEIGHT() { return TOC_VIRTUAL_ROW_HEIGHT; }, + get acquirePdfRenderSlot() { return acquirePdfRenderSlot; }, set acquirePdfRenderSlot(value) { acquirePdfRenderSlot = value; }, + get acquirePdfTextSlot() { return acquirePdfTextSlot; }, set acquirePdfTextSlot(value) { acquirePdfTextSlot = value; }, + get activateFoliateTocEntry() { return activateFoliateTocEntry; }, set activateFoliateTocEntry(value) { activateFoliateTocEntry = value; }, + get applyChapterSearchPage() { return applyChapterSearchPage; }, + get applyInitialRestoration() { return applyInitialRestoration; }, set applyInitialRestoration(value) { applyInitialRestoration = value; }, + get applyReaderMetadata() { return applyReaderMetadata; }, set applyReaderMetadata(value) { applyReaderMetadata = value; }, + get assertReaderActive() { return assertReaderActive; }, set assertReaderActive(value) { assertReaderActive = value; }, + get awaitReader() { return awaitReader; }, set awaitReader(value) { awaitReader = value; }, + get beginReaderNavigation() { return beginReaderNavigation; }, set beginReaderNavigation(value) { beginReaderNavigation = value; }, + get bookmarkExcerptInput() { return bookmarkExcerptInput; }, + get bookmarkInertSiblings() { return bookmarkInertSiblings; }, + get bookmarkInvoker() { return bookmarkInvoker; }, set bookmarkInvoker(value) { bookmarkInvoker = value; }, + get bookmarkLabelInput() { return bookmarkLabelInput; }, + get bookmarkPopover() { return bookmarkPopover; }, + get bookmarkRibbon() { return bookmarkRibbon; }, + get cachedReaderData() { return cachedReaderData; }, set cachedReaderData(value) { cachedReaderData = value; }, + get cancelSpeculativePdfRenders() { return cancelSpeculativePdfRenders; }, set cancelSpeculativePdfRenders(value) { cancelSpeculativePdfRenders = value; }, + get capability() { return capability; }, set capability(value) { capability = value; }, + get captureChapterPosition() { return captureChapterPosition; }, set captureChapterPosition(value) { captureChapterPosition = value; }, + get captureFoliateBookmarkPosition() { return captureFoliateBookmarkPosition; }, set captureFoliateBookmarkPosition(value) { captureFoliateBookmarkPosition = value; }, + get chapterManifestCleanup() { return chapterManifestCleanup; }, set chapterManifestCleanup(value) { chapterManifestCleanup = value; }, + get chapterManifestLoader() { return chapterManifestLoader; }, set chapterManifestLoader(value) { chapterManifestLoader = value; }, + get chapterManifestObserver() { return chapterManifestObserver; }, set chapterManifestObserver(value) { chapterManifestObserver = value; }, + get chapterManifestUrl() { return chapterManifestUrl; }, set chapterManifestUrl(value) { chapterManifestUrl = value; }, + get chapterNext() { return chapterNext; }, + get chapterPrev() { return chapterPrev; }, + get chapterSearchClient() { return chapterSearchClient; }, set chapterSearchClient(value) { chapterSearchClient = value; }, + get chapterSearchConfiguration() { return chapterSearchConfiguration; }, set chapterSearchConfiguration(value) { chapterSearchConfiguration = value; }, + get chapterSearchModulePromise() { return chapterSearchModulePromise; }, set chapterSearchModulePromise(value) { chapterSearchModulePromise = value; }, + get classifyReaderError() { return classifyReaderError; }, set classifyReaderError(value) { classifyReaderError = value; }, + get clearFullSearchMarks() { return clearFullSearchMarks; }, + get clearPdfManifestPrefetches() { return clearPdfManifestPrefetches; }, set clearPdfManifestPrefetches(value) { clearPdfManifestPrefetches = value; }, + get clearSearchHighlights() { return clearSearchHighlights; }, set clearSearchHighlights(value) { clearSearchHighlights = value; }, + get consumeBookmarkHandoff() { return consumeBookmarkHandoff; }, set consumeBookmarkHandoff(value) { consumeBookmarkHandoff = value; }, + get content() { return content; }, + get contentSanitizerObserver() { return contentSanitizerObserver; }, set contentSanitizerObserver(value) { contentSanitizerObserver = value; }, + get contentUrl() { return contentUrl; }, set contentUrl(value) { contentUrl = value; }, + get createPdfFetchPolicy() { return createPdfFetchPolicy; }, + get createPdfV3Repository() { return createPdfV3Repository; }, set createPdfV3Repository(value) { createPdfV3Repository = value; }, + get createV3Repository() { return createV3Repository; }, + get detectTextEncoding() { return detectTextEncoding; }, set detectTextEncoding(value) { detectTextEncoding = value; }, + get documentState() { return documentState; }, + get docxPageNodes() { return docxPageNodes; }, set docxPageNodes(value) { docxPageNodes = value; }, + get domSearch() { return domSearch; }, + get downloadButton() { return downloadButton; }, + get downloadFeedback() { return downloadFeedback; }, + get downloadFeedbackTimer() { return downloadFeedbackTimer; }, set downloadFeedbackTimer(value) { downloadFeedbackTimer = value; }, + get downloadUrl() { return downloadUrl; }, set downloadUrl(value) { downloadUrl = value; }, + get drainPdfTextWaiters() { return drainPdfTextWaiters; }, set drainPdfTextWaiters(value) { drainPdfTextWaiters = value; }, + get emptyPanel() { return emptyPanel; }, set emptyPanel(value) { emptyPanel = value; }, + get epubBook() { return epubBook; }, set epubBook(value) { epubBook = value; }, + get epubContentBody() { return epubContentBody; }, set epubContentBody(value) { epubContentBody = value; }, + get epubRendition() { return epubRendition; }, set epubRendition(value) { epubRendition = value; }, + get epubSeekFrame() { return epubSeekFrame; }, set epubSeekFrame(value) { epubSeekFrame = value; }, + get epubSeekPromise() { return epubSeekPromise; }, set epubSeekPromise(value) { epubSeekPromise = value; }, + get epubSeekTarget() { return epubSeekTarget; }, set epubSeekTarget(value) { epubSeekTarget = value; }, + get extension() { return extension; }, set extension(value) { extension = value; }, + get fail() { return fail; }, set fail(value) { fail = value; }, + get fallbackUrl() { return fallbackUrl; }, set fallbackUrl(value) { fallbackUrl = value; }, + get fetchReaderResponse() { return fetchReaderResponse; }, set fetchReaderResponse(value) { fetchReaderResponse = value; }, + get fetchReaderUrl() { return fetchReaderUrl; }, set fetchReaderUrl(value) { fetchReaderUrl = value; }, + get filterPanel() { return filterPanel; }, set filterPanel(value) { filterPanel = value; }, + get folderNavigationTarget() { return folderNavigationTarget; }, set folderNavigationTarget(value) { folderNavigationTarget = value; }, + get folderReturnUrl() { return folderReturnUrl; }, + get foliateChapterRepository() { return foliateChapterRepository; }, set foliateChapterRepository(value) { foliateChapterRepository = value; }, + get foliateContinuous() { return foliateContinuous; }, set foliateContinuous(value) { foliateContinuous = value; }, + get foliateScrollAnchors() { return foliateScrollAnchors; }, + get foliateScrollFrame() { return foliateScrollFrame; }, set foliateScrollFrame(value) { foliateScrollFrame = value; }, + get foliateSectionLoader() { return foliateSectionLoader; }, set foliateSectionLoader(value) { foliateSectionLoader = value; }, + get foliateSectionObserver() { return foliateSectionObserver; }, set foliateSectionObserver(value) { foliateSectionObserver = value; }, + get foliateSectionRoot() { return foliateSectionRoot; }, set foliateSectionRoot(value) { foliateSectionRoot = value; }, + get foliateSectionSettler() { return foliateSectionSettler; }, set foliateSectionSettler(value) { foliateSectionSettler = value; }, + get foliateSectionVirtualizer() { return foliateSectionVirtualizer; }, set foliateSectionVirtualizer(value) { foliateSectionVirtualizer = value; }, + get foliateWindowFrame() { return foliateWindowFrame; }, set foliateWindowFrame(value) { foliateWindowFrame = value; }, + get formatAdapters() { return formatAdapters; }, + get formatMediaTime() { return formatMediaTime; }, set formatMediaTime(value) { formatMediaTime = value; }, + get fullSearchButton() { return fullSearchButton; }, + get fullSearchDomMatches() { return fullSearchDomMatches; }, + get fullSearchEscape() { return fullSearchEscape; }, + get fullSearchInput() { return fullSearchInput; }, + get fullSearchPageOffset() { return fullSearchPageOffset; }, + get fullSearchPdfMatches() { return fullSearchPdfMatches; }, set fullSearchPdfMatches(value) { fullSearchPdfMatches = value; }, + get fullSearchSession() { return fullSearchSession; }, + get fullSearchSnippet() { return fullSearchSnippet; }, + get fullSearchStatus() { return fullSearchStatus; }, + get getInitialReaderRestoration() { return getInitialReaderRestoration; }, set getInitialReaderRestoration(value) { getInitialReaderRestoration = value; }, + get goToPage() { return goToPage; }, set goToPage(value) { goToPage = value; }, + get handlePageNavigationFailure() { return handlePageNavigationFailure; }, + get handleReaderKeydown() { return handleReaderKeydown; }, set handleReaderKeydown(value) { handleReaderKeydown = value; }, + get handleReaderPositionChange() { return handleReaderPositionChange; }, set handleReaderPositionChange(value) { handleReaderPositionChange = value; }, + get headingLocationCache() { return headingLocationCache; }, set headingLocationCache(value) { headingLocationCache = value; }, + get headingTocEntries() { return headingTocEntries; }, set headingTocEntries(value) { headingTocEntries = value; }, + get highlightPdfText() { return highlightPdfText; }, set highlightPdfText(value) { highlightPdfText = value; }, + get highlightTextParts() { return highlightTextParts; }, + get historySuppressed() { return historySuppressed; }, set historySuppressed(value) { historySuppressed = value; }, + get hitBoxes() { return hitBoxes; }, + get htmlFrame() { return htmlFrame; }, set htmlFrame(value) { htmlFrame = value; }, + get imageLoader() { return imageLoader; }, + get independentTextCaret() { return independentTextCaret; }, + get initialReaderPage() { return initialReaderPage; }, set initialReaderPage(value) { initialReaderPage = value; }, + get initialReaderTargetSection() { return initialReaderTargetSection; }, set initialReaderTargetSection(value) { initialReaderTargetSection = value; }, + get installReaderTapSelectionClear() { return installReaderTapSelectionClear; }, set installReaderTapSelectionClear(value) { installReaderTapSelectionClear = value; }, + get isPdfPageVisible() { return isPdfPageVisible; }, set isPdfPageVisible(value) { isPdfPageVisible = value; }, + get isReaderGenerationCurrent() { return isReaderGenerationCurrent; }, set isReaderGenerationCurrent(value) { isReaderGenerationCurrent = value; }, + get lastSavedProgress() { return lastSavedProgress; }, set lastSavedProgress(value) { lastSavedProgress = value; }, + get loadChapterManifestDocument() { return loadChapterManifestDocument; }, set loadChapterManifestDocument(value) { loadChapterManifestDocument = value; }, + get loadDocxDocument() { return loadDocxDocument; }, set loadDocxDocument(value) { loadDocxDocument = value; }, + get loadHtmlDocument() { return loadHtmlDocument; }, set loadHtmlDocument(value) { loadHtmlDocument = value; }, + get loadImageDocument() { return loadImageDocument; }, set loadImageDocument(value) { loadImageDocument = value; }, + get loadMarkdownDocument() { return loadMarkdownDocument; }, set loadMarkdownDocument(value) { loadMarkdownDocument = value; }, + get loadMediaDocument() { return loadMediaDocument; }, set loadMediaDocument(value) { loadMediaDocument = value; }, + get loadPdfDocument() { return loadPdfDocument; }, set loadPdfDocument(value) { loadPdfDocument = value; }, + get loadPdfOcrManifest() { return loadPdfOcrManifest; }, set loadPdfOcrManifest(value) { loadPdfOcrManifest = value; }, + get loadPdfOcrPage() { return loadPdfOcrPage; }, set loadPdfOcrPage(value) { loadPdfOcrPage = value; }, + get loadPdfTextContent() { return loadPdfTextContent; }, set loadPdfTextContent(value) { loadPdfTextContent = value; }, + get loadReaderRestoration() { return loadReaderRestoration; }, set loadReaderRestoration(value) { loadReaderRestoration = value; }, + get loadScript() { return loadScript; }, set loadScript(value) { loadScript = value; }, + get loadSwfDocument() { return loadSwfDocument; }, set loadSwfDocument(value) { loadSwfDocument = value; }, + get loadTextDocument() { return loadTextDocument; }, set loadTextDocument(value) { loadTextDocument = value; }, + get loadingIndicator() { return loadingIndicator; }, + get loadingStatus() { return loadingStatus; }, + get localReaderData() { return localReaderData; }, set localReaderData(value) { localReaderData = value; }, + get markReaderContentReady() { return markReaderContentReady; }, set markReaderContentReady(value) { markReaderContentReady = value; }, + get markerFrame() { return markerFrame; }, set markerFrame(value) { markerFrame = value; }, + get measurePdf() { return measurePdf; }, set measurePdf(value) { measurePdf = value; }, + get mediaElement() { return mediaElement; }, set mediaElement(value) { mediaElement = value; }, + get mediaPanel() { return mediaPanel; }, + get mediaTab() { return mediaTab; }, + get navigateReader() { return navigateReader; }, set navigateReader(value) { navigateReader = value; }, + get navigateTocEntry() { return navigateTocEntry; }, set navigateTocEntry(value) { navigateTocEntry = value; }, + get navigationState() { return navigationState; }, + get nextReaderGeneration() { return nextReaderGeneration; }, set nextReaderGeneration(value) { nextReaderGeneration = value; }, + get normalizePdfPriority() { return normalizePdfPriority; }, set normalizePdfPriority(value) { normalizePdfPriority = value; }, + get normalizePdfSearchText() { return normalizePdfSearchText; }, + get normalizeSourceUrl() { return normalizeSourceUrl; }, set normalizeSourceUrl(value) { normalizeSourceUrl = value; }, + get ocrManifestUrl() { return ocrManifestUrl; }, set ocrManifestUrl(value) { ocrManifestUrl = value; }, + get ocrUrl() { return ocrUrl; }, + get pageAtMarker() { return pageAtMarker; }, set pageAtMarker(value) { pageAtMarker = value; }, + get pageInput() { return pageInput; }, + get pageInvertToggle() { return pageInvertToggle; }, + get pageInverted() { return pageInverted; }, set pageInverted(value) { pageInverted = value; }, + get pageNavigationLockUntil() { return pageNavigationLockUntil; }, set pageNavigationLockUntil(value) { pageNavigationLockUntil = value; }, + get paintTextHit() { return paintTextHit; }, + get panelAnimationTimer() { return panelAnimationTimer; }, set panelAnimationTimer(value) { panelAnimationTimer = value; }, + get panelState() { return panelState; }, + get params() { return params; }, + get parseChapterDocument() { return parseChapterDocument; }, set parseChapterDocument(value) { parseChapterDocument = value; }, + get pdfActiveRenders() { return pdfActiveRenders; }, set pdfActiveRenders(value) { pdfActiveRenders = value; }, + get pdfActiveSearchTextLoads() { return pdfActiveSearchTextLoads; }, set pdfActiveSearchTextLoads(value) { pdfActiveSearchTextLoads = value; }, + get pdfActiveTextLoads() { return pdfActiveTextLoads; }, set pdfActiveTextLoads(value) { pdfActiveTextLoads = value; }, + get pdfBookSearchClient() { return pdfBookSearchClient; }, set pdfBookSearchClient(value) { pdfBookSearchClient = value; }, + get pdfBookSearchModulePromise() { return pdfBookSearchModulePromise; }, set pdfBookSearchModulePromise(value) { pdfBookSearchModulePromise = value; }, + get pdfBookSearchPrefetchPromise() { return pdfBookSearchPrefetchPromise; }, set pdfBookSearchPrefetchPromise(value) { pdfBookSearchPrefetchPromise = value; }, + get pdfBookSearchPrefetchTimer() { return pdfBookSearchPrefetchTimer; }, set pdfBookSearchPrefetchTimer(value) { pdfBookSearchPrefetchTimer = value; }, + get pdfDocument() { return pdfDocument; }, set pdfDocument(value) { pdfDocument = value; }, + get pdfEnginePreload() { return pdfEnginePreload; }, set pdfEnginePreload(value) { pdfEnginePreload = value; }, + get pdfFetchPolicy() { return pdfFetchPolicy; }, set pdfFetchPolicy(value) { pdfFetchPolicy = value; }, + get pdfFirstPagePreload() { return pdfFirstPagePreload; }, set pdfFirstPagePreload(value) { pdfFirstPagePreload = value; }, + get pdfFirstPagePreloadPage() { return pdfFirstPagePreloadPage; }, set pdfFirstPagePreloadPage(value) { pdfFirstPagePreloadPage = value; }, + get pdfHybridDocumentPromise() { return pdfHybridDocumentPromise; }, set pdfHybridDocumentPromise(value) { pdfHybridDocumentPromise = value; }, + get pdfHybridPreviewLoads() { return pdfHybridPreviewLoads; }, set pdfHybridPreviewLoads(value) { pdfHybridPreviewLoads = value; }, + get pdfHybridUrl() { return pdfHybridUrl; }, set pdfHybridUrl(value) { pdfHybridUrl = value; }, + get pdfLastScrollTop() { return pdfLastScrollTop; }, set pdfLastScrollTop(value) { pdfLastScrollTop = value; }, + get pdfManifestPrefetchActive() { return pdfManifestPrefetchActive; }, + get pdfManifestPrefetchCount() { return pdfManifestPrefetchCount; }, set pdfManifestPrefetchCount(value) { pdfManifestPrefetchCount = value; }, + get pdfManifestPrefetchNext() { return pdfManifestPrefetchNext; }, set pdfManifestPrefetchNext(value) { pdfManifestPrefetchNext = value; }, + get pdfManifestPrefetchOrigin() { return pdfManifestPrefetchOrigin; }, set pdfManifestPrefetchOrigin(value) { pdfManifestPrefetchOrigin = value; }, + get pdfManifestPrefetchTimer() { return pdfManifestPrefetchTimer; }, set pdfManifestPrefetchTimer(value) { pdfManifestPrefetchTimer = value; }, + get pdfManifestPrefetchWrapped() { return pdfManifestPrefetchWrapped; }, set pdfManifestPrefetchWrapped(value) { pdfManifestPrefetchWrapped = value; }, + get pdfManifestPrefetches() { return pdfManifestPrefetches; }, + get pdfManifestShells() { return pdfManifestShells; }, + get pdfOcrManifest() { return pdfOcrManifest; }, set pdfOcrManifest(value) { pdfOcrManifest = value; }, + get pdfOcrManifestPromise() { return pdfOcrManifestPromise; }, set pdfOcrManifestPromise(value) { pdfOcrManifestPromise = value; }, + get pdfOcrPageCache() { return pdfOcrPageCache; }, + get pdfOcrPageCacheBytes() { return pdfOcrPageCacheBytes; }, set pdfOcrPageCacheBytes(value) { pdfOcrPageCacheBytes = value; }, + get pdfOcrPagePromises() { return pdfOcrPagePromises; }, + get pdfOcrSource() { return pdfOcrSource; }, set pdfOcrSource(value) { pdfOcrSource = value; }, + get pdfPageEntry() { return pdfPageEntry; }, set pdfPageEntry(value) { pdfPageEntry = value; }, + get pdfPageManifest() { return pdfPageManifest; }, set pdfPageManifest(value) { pdfPageManifest = value; }, + get pdfPagePriority() { return pdfPagePriority; }, set pdfPagePriority(value) { pdfPagePriority = value; }, + get pdfPageTimings() { return pdfPageTimings; }, + get pdfPersistentTextOpening() { return pdfPersistentTextOpening; }, set pdfPersistentTextOpening(value) { pdfPersistentTextOpening = value; }, + get pdfPersistentTextStore() { return pdfPersistentTextStore; }, set pdfPersistentTextStore(value) { pdfPersistentTextStore = value; }, + get pdfPersistentTextVersion() { return pdfPersistentTextVersion; }, set pdfPersistentTextVersion(value) { pdfPersistentTextVersion = value; }, + get pdfPrimarySource() { return pdfPrimarySource; }, set pdfPrimarySource(value) { pdfPrimarySource = value; }, + get pdfRenderWaiters() { return pdfRenderWaiters; }, + get pdfScrollDirection() { return pdfScrollDirection; }, set pdfScrollDirection(value) { pdfScrollDirection = value; }, + get pdfSearchText() { return pdfSearchText; }, + get pdfSearchTextCache() { return pdfSearchTextCache; }, + get pdfSearchTextCacheBytes() { return pdfSearchTextCacheBytes; }, set pdfSearchTextCacheBytes(value) { pdfSearchTextCacheBytes = value; }, + get pdfSelectionIntersects() { return pdfSelectionIntersects; }, set pdfSelectionIntersects(value) { pdfSelectionIntersects = value; }, + get pdfShellWindow() { return pdfShellWindow; }, set pdfShellWindow(value) { pdfShellWindow = value; }, + get pdfShellsReady() { return pdfShellsReady; }, set pdfShellsReady(value) { pdfShellsReady = value; }, + get pdfSourceOffset() { return pdfSourceOffset; }, + get pdfTextContent() { return pdfTextContent; }, + get pdfTextContentCache() { return pdfTextContentCache; }, + get pdfTextContentCacheBytes() { return pdfTextContentCacheBytes; }, set pdfTextContentCacheBytes(value) { pdfTextContentCacheBytes = value; }, + get pdfTextContentPromises() { return pdfTextContentPromises; }, + get pdfTextLayerRepository() { return pdfTextLayerRepository; }, set pdfTextLayerRepository(value) { pdfTextLayerRepository = value; }, + get pdfTextWaiters() { return pdfTextWaiters; }, + get pdfUserHasScrolled() { return pdfUserHasScrolled; }, set pdfUserHasScrolled(value) { pdfUserHasScrolled = value; }, + get pdfV3BlobReleases() { return pdfV3BlobReleases; }, + get pdfV3Manifest() { return pdfV3Manifest; }, set pdfV3Manifest(value) { pdfV3Manifest = value; }, + get pdfV3Map() { return pdfV3Map; }, set pdfV3Map(value) { pdfV3Map = value; }, + get pdfV3Repository() { return pdfV3Repository; }, set pdfV3Repository(value) { pdfV3Repository = value; }, + get pdfWorker() { return pdfWorker; }, set pdfWorker(value) { pdfWorker = value; }, + get pendingBookmarkSnapshot() { return pendingBookmarkSnapshot; }, set pendingBookmarkSnapshot(value) { pendingBookmarkSnapshot = value; }, + get populateIndependentTextLayer() { return populateIndependentTextLayer; }, + get populateOcrTextLayer() { return populateOcrTextLayer; }, + get populatePdfTextLayer() { return populatePdfTextLayer; }, + get preloadPdfEngine() { return preloadPdfEngine; }, set preloadPdfEngine(value) { preloadPdfEngine = value; }, + get prepareReaderDocument() { return prepareReaderDocument; }, set prepareReaderDocument(value) { prepareReaderDocument = value; }, + get previousProgressState() { return previousProgressState; }, set previousProgressState(value) { previousProgressState = value; }, + get prioritizePdfManifestPrefetch() { return prioritizePdfManifestPrefetch; }, set prioritizePdfManifestPrefetch(value) { prioritizePdfManifestPrefetch = value; }, + get progressPercent() { return progressPercent; }, + get progressRange() { return progressRange; }, + get progressSaveChain() { return progressSaveChain; }, set progressSaveChain(value) { progressSaveChain = value; }, + get progressSeeking() { return progressSeeking; }, set progressSeeking(value) { progressSeeking = value; }, + get progressTools() { return progressTools; }, + get progressUndo() { return progressUndo; }, + get promotePdfRenderWaiter() { return promotePdfRenderWaiter; }, set promotePdfRenderWaiter(value) { promotePdfRenderWaiter = value; }, + get queuePdfManifestPrefetch() { return queuePdfManifestPrefetch; }, set queuePdfManifestPrefetch(value) { queuePdfManifestPrefetch = value; }, + get readPreparedBytes() { return readPreparedBytes; }, set readPreparedBytes(value) { readPreparedBytes = value; }, + get readerAbortController() { return readerAbortController; }, + get readerAbortError() { return readerAbortError; }, set readerAbortError(value) { readerAbortError = value; }, + get readerContentUrl() { return readerContentUrl; }, set readerContentUrl(value) { readerContentUrl = value; }, + get readerDownloadApiUrl() { return readerDownloadApiUrl; }, set readerDownloadApiUrl(value) { readerDownloadApiUrl = value; }, + get readerDownloadUrl() { return readerDownloadUrl; }, set readerDownloadUrl(value) { readerDownloadUrl = value; }, + get readerId() { return readerId; }, + get readerLifecycle() { return readerLifecycle; }, + get readerPageScrollTop() { return readerPageScrollTop; }, set readerPageScrollTop(value) { readerPageScrollTop = value; }, + get readerPanelInteractionSerial() { return readerPanelInteractionSerial; }, set readerPanelInteractionSerial(value) { readerPanelInteractionSerial = value; }, + get readerPath() { return readerPath; }, + get readerPathLabel() { return readerPathLabel; }, + get readerProgressPercent() { return readerProgressPercent; }, set readerProgressPercent(value) { readerProgressPercent = value; }, + get readerRequestManager() { return readerRequestManager; }, + get readerResources() { return readerResources; }, + get readerRestorationEntry() { return readerRestorationEntry; }, set readerRestorationEntry(value) { readerRestorationEntry = value; }, + get readerRestorationLoaded() { return readerRestorationLoaded; }, set readerRestorationLoaded(value) { readerRestorationLoaded = value; }, + get readerRestorationPromise() { return readerRestorationPromise; }, set readerRestorationPromise(value) { readerRestorationPromise = value; }, + get readerRuntime() { return readerRuntime; }, + get readerSourcePromise() { return readerSourcePromise; }, set readerSourcePromise(value) { readerSourcePromise = value; }, + get readerTheme() { return readerTheme; }, set readerTheme(value) { readerTheme = value; }, + get readerThemeToggle() { return readerThemeToggle; }, + get readerUiHistoryState() { return readerUiHistoryState; }, set readerUiHistoryState(value) { readerUiHistoryState = value; }, + get readingProgressSummary() { return readingProgressSummary; }, + get refreshNearbyPdfPages() { return refreshNearbyPdfPages; }, set refreshNearbyPdfPages(value) { refreshNearbyPdfPages = value; }, + get releasePdfRenderSlot() { return releasePdfRenderSlot; }, set releasePdfRenderSlot(value) { releasePdfRenderSlot = value; }, + get releasePdfTextSlot() { return releasePdfTextSlot; }, set releasePdfTextSlot(value) { releasePdfTextSlot = value; }, + get releasePdfWorker() { return releasePdfWorker; }, set releasePdfWorker(value) { releasePdfWorker = value; }, + get renderBookmarks() { return renderBookmarks; }, set renderBookmarks(value) { renderBookmarks = value; }, + get renderChapterManifest() { return renderChapterManifest; }, set renderChapterManifest(value) { renderChapterManifest = value; }, + get renderDocx() { return renderDocx; }, set renderDocx(value) { renderDocx = value; }, + get renderFoliate() { return renderFoliate; }, set renderFoliate(value) { renderFoliate = value; }, + get renderFullSearchResults() { return renderFullSearchResults; }, + get renderHtml() { return renderHtml; }, set renderHtml(value) { renderHtml = value; }, + get renderImageDocument() { return renderImageDocument; }, set renderImageDocument(value) { renderImageDocument = value; }, + get renderImagePages() { return renderImagePages; }, set renderImagePages(value) { renderImagePages = value; }, + get renderMarkdownDocument() { return renderMarkdownDocument; }, set renderMarkdownDocument(value) { renderMarkdownDocument = value; }, + get renderMedia() { return renderMedia; }, set renderMedia(value) { renderMedia = value; }, + get renderPdf() { return renderPdf; }, set renderPdf(value) { renderPdf = value; }, + get renderPdfInBackground() { return renderPdfInBackground; }, set renderPdfInBackground(value) { renderPdfInBackground = value; }, + get renderPdfManifestShell() { return renderPdfManifestShell; }, set renderPdfManifestShell(value) { renderPdfManifestShell = value; }, + get renderPdfOcrText() { return renderPdfOcrText; }, set renderPdfOcrText(value) { renderPdfOcrText = value; }, + get renderPdfPages() { return renderPdfPages; }, set renderPdfPages(value) { renderPdfPages = value; }, + get renderPdfShell() { return renderPdfShell; }, set renderPdfShell(value) { renderPdfShell = value; }, + get renderPdfText() { return renderPdfText; }, set renderPdfText(value) { renderPdfText = value; }, + get renderPlainText() { return renderPlainText; }, set renderPlainText(value) { renderPlainText = value; }, + get renderSwf() { return renderSwf; }, set renderSwf(value) { renderSwf = value; }, + get renderText() { return renderText; }, set renderText(value) { renderText = value; }, + get renderTextDocument() { return renderTextDocument; }, set renderTextDocument(value) { renderTextDocument = value; }, + get reportNavigationError() { return reportNavigationError; }, set reportNavigationError(value) { reportNavigationError = value; }, + get rerenderVisiblePdfPages() { return rerenderVisiblePdfPages; }, set rerenderVisiblePdfPages(value) { rerenderVisiblePdfPages = value; }, + get resolveReaderId() { return resolveReaderId; }, set resolveReaderId(value) { resolveReaderId = value; }, + get resolvedReaderData() { return resolvedReaderData; }, set resolvedReaderData(value) { resolvedReaderData = value; }, + get restorationFailed() { return restorationFailed; }, set restorationFailed(value) { restorationFailed = value; }, + get restoreChapterPosition() { return restoreChapterPosition; }, set restoreChapterPosition(value) { restoreChapterPosition = value; }, + get restoreFoliateBookmarkPosition() { return restoreFoliateBookmarkPosition; }, set restoreFoliateBookmarkPosition(value) { restoreFoliateBookmarkPosition = value; }, + get restoreFormat() { return restoreFormat; }, set restoreFormat(value) { restoreFormat = value; }, + get retryReaderProxy() { return retryReaderProxy; }, set retryReaderProxy(value) { retryReaderProxy = value; }, + get retryableReaderProxyError() { return retryableReaderProxyError; }, set retryableReaderProxyError(value) { retryableReaderProxyError = value; }, + get returnHistoryKey() { return returnHistoryKey; }, + get runFullSearch() { return runFullSearch; }, + get sanitizeEpubDocument() { return sanitizeEpubDocument; }, set sanitizeEpubDocument(value) { sanitizeEpubDocument = value; }, + get saveDeadline() { return saveDeadline; }, set saveDeadline(value) { saveDeadline = value; }, + get saveProgress() { return saveProgress; }, set saveProgress(value) { saveProgress = value; }, + get saveTimer() { return saveTimer; }, set saveTimer(value) { saveTimer = value; }, + get scheduleFoliateScrollSync() { return scheduleFoliateScrollSync; }, set scheduleFoliateScrollSync(value) { scheduleFoliateScrollSync = value; }, + get scheduleMarkerSync() { return scheduleMarkerSync; }, set scheduleMarkerSync(value) { scheduleMarkerSync = value; }, + get schedulePdfBookSearchPrefetch() { return schedulePdfBookSearchPrefetch; }, set schedulePdfBookSearchPrefetch(value) { schedulePdfBookSearchPrefetch = value; }, + get schedulePdfManifestPrefetch() { return schedulePdfManifestPrefetch; }, set schedulePdfManifestPrefetch(value) { schedulePdfManifestPrefetch = value; }, + get scheduleSave() { return scheduleSave; }, set scheduleSave(value) { scheduleSave = value; }, + get searchConcentratedPdf() { return searchConcentratedPdf; }, set searchConcentratedPdf(value) { searchConcentratedPdf = value; }, + get searchState() { return searchState; }, + get seekFoliateProgress() { return seekFoliateProgress; }, set seekFoliateProgress(value) { seekFoliateProgress = value; }, + get selectPanel() { return selectPanel; }, set selectPanel(value) { selectPanel = value; }, + get setPdfPageLoadingState() { return setPdfPageLoadingState; }, set setPdfPageLoadingState(value) { setPdfPageLoadingState = value; }, + get setReaderPanelOpen() { return setReaderPanelOpen; }, set setReaderPanelOpen(value) { setReaderPanelOpen = value; }, + get setReaderPhase() { return setReaderPhase; }, set setReaderPhase(value) { setReaderPhase = value; }, + get setReaderStage() { return setReaderStage; }, set setReaderStage(value) { setReaderStage = value; }, + get setToc() { return setToc; }, set setToc(value) { setToc = value; }, + get setZoom() { return setZoom; }, set setZoom(value) { setZoom = value; }, + get settleReaderImages() { return settleReaderImages; }, set settleReaderImages(value) { settleReaderImages = value; }, + get showDownloadFeedback() { return showDownloadFeedback; }, set showDownloadFeedback(value) { showDownloadFeedback = value; }, + get sourceName() { return sourceName; }, + get sourceUrl() { return sourceUrl; }, set sourceUrl(value) { sourceUrl = value; }, + get status() { return status; }, + get swfPlayer() { return swfPlayer; }, set swfPlayer(value) { swfPlayer = value; }, + get syncCapabilityControls() { return syncCapabilityControls; }, set syncCapabilityControls(value) { syncCapabilityControls = value; }, + get syncCurrentPageFromMarker() { return syncCurrentPageFromMarker; }, set syncCurrentPageFromMarker(value) { syncCurrentPageFromMarker = value; }, + get syncFoliateScrollLocation() { return syncFoliateScrollLocation; }, set syncFoliateScrollLocation(value) { syncFoliateScrollLocation = value; }, + get syncHeadingLocation() { return syncHeadingLocation; }, set syncHeadingLocation(value) { syncHeadingLocation = value; }, + get takePdfManifestPrefetch() { return takePdfManifestPrefetch; }, set takePdfManifestPrefetch(value) { takePdfManifestPrefetch = value; }, + get themeAnimationTimer() { return themeAnimationTimer; }, set themeAnimationTimer(value) { themeAnimationTimer = value; }, + get tocVirtualState() { return tocVirtualState; }, set tocVirtualState(value) { tocVirtualState = value; }, + get trackReaderResource() { return trackReaderResource; }, set trackReaderResource(value) { trackReaderResource = value; }, + get trimPdfCanvases() { return trimPdfCanvases; }, set trimPdfCanvases(value) { trimPdfCanvases = value; }, + get trimPdfManifestImages() { return trimPdfManifestImages; }, set trimPdfManifestImages(value) { trimPdfManifestImages = value; }, + get trustedChapterReferenceUrl() { return trustedChapterReferenceUrl; }, set trustedChapterReferenceUrl(value) { trustedChapterReferenceUrl = value; }, + get trustedChapterResourceUrl() { return trustedChapterResourceUrl; }, set trustedChapterResourceUrl(value) { trustedChapterResourceUrl = value; }, + get trustedChapterUrl() { return trustedChapterUrl; }, set trustedChapterUrl(value) { trustedChapterUrl = value; }, + get trustedReaderAssetManifestUrl() { return trustedReaderAssetManifestUrl; }, set trustedReaderAssetManifestUrl(value) { trustedReaderAssetManifestUrl = value; }, + get updateDocumentState() { return updateDocumentState; }, set updateDocumentState(value) { updateDocumentState = value; }, + get updateNavigationState() { return updateNavigationState; }, set updateNavigationState(value) { updateNavigationState = value; }, + get updatePanelState() { return updatePanelState; }, set updatePanelState(value) { updatePanelState = value; }, + get updateProgressTools() { return updateProgressTools; }, set updateProgressTools(value) { updateProgressTools = value; }, + get updateSearchState() { return updateSearchState; }, set updateSearchState(value) { updateSearchState = value; }, + get updateTocCurrentMark() { return updateTocCurrentMark; }, set updateTocCurrentMark(value) { updateTocCurrentMark = value; }, + get v3ResourceUrl() { return v3ResourceUrl; }, set v3ResourceUrl(value) { v3ResourceUrl = value; }, + get validFallback() { return validFallback; }, set validFallback(value) { validFallback = value; }, + get validOcr() { return validOcr; }, set validOcr(value) { validOcr = value; }, + get validSource() { return validSource; }, set validSource(value) { validSource = value; }, + get validatePageMap() { return validatePageMap; }, + get validateReadingManifest() { return validateReadingManifest; }, + get validateResource() { return validateResource; }, + get viewport() { return viewport; }, + get waitForReader() { return waitForReader; }, set waitForReader(value) { waitForReader = value; }, + get zoomChangedByUser() { return zoomChangedByUser; }, set zoomChangedByUser(value) { zoomChangedByUser = value; }, + get zoomInput() { return zoomInput; }, + get activateChapter() { return activateChapter; }, set activateChapter(value) { activateChapter = value; }, + get adjacentTocIndex() { return adjacentTocIndex; }, set adjacentTocIndex(value) { adjacentTocIndex = value; }, + get applyPageInversion() { return applyPageInversion; }, set applyPageInversion(value) { applyPageInversion = value; }, + get applyReaderTheme() { return applyReaderTheme; }, set applyReaderTheme(value) { applyReaderTheme = value; }, + get bookmarkEditFields() { return bookmarkEditFields; }, + get bookmarksAllButton() { return bookmarksAllButton; }, + get bookmarksHeader() { return bookmarksHeader; }, + get bookmarksHeaderActions() { return bookmarksHeaderActions; }, + get bookmarksSearchButton() { return bookmarksSearchButton; }, + get canReturnWithHistory() { return canReturnWithHistory; }, set canReturnWithHistory(value) { canReturnWithHistory = value; }, + get captureBookmarkSnapshot() { return captureBookmarkSnapshot; }, set captureBookmarkSnapshot(value) { captureBookmarkSnapshot = value; }, + get captureProgressState() { return captureProgressState; }, set captureProgressState(value) { captureProgressState = value; }, + get clearReturnNavigation() { return clearReturnNavigation; }, set clearReturnNavigation(value) { clearReturnNavigation = value; }, + get closeBookmarkPopover() { return closeBookmarkPopover; }, set closeBookmarkPopover(value) { closeBookmarkPopover = value; }, + get closeFullSearchView() { return closeFullSearchView; }, + get correctPdfCrossPageSelection() { return correctPdfCrossPageSelection; }, set correctPdfCrossPageSelection(value) { correctPdfCrossPageSelection = value; }, + get currentReaderUrl() { return currentReaderUrl; }, set currentReaderUrl(value) { currentReaderUrl = value; }, + get disposeReader() { return disposeReader; }, set disposeReader(value) { disposeReader = value; }, + get flushEpubSeek() { return flushEpubSeek; }, set flushEpubSeek(value) { flushEpubSeek = value; }, + get fullSearchController() { return fullSearchController; }, + get fullSearchView() { return fullSearchView; }, + get navigateReaderFolder() { return navigateReaderFolder; }, set navigateReaderFolder(value) { navigateReaderFolder = value; }, + get notePdfScrollIntent() { return notePdfScrollIntent; }, set notePdfScrollIntent(value) { notePdfScrollIntent = value; }, + get openBookmarkPopover() { return openBookmarkPopover; }, set openBookmarkPopover(value) { openBookmarkPopover = value; }, + get pdfDiagnostics() { return pdfDiagnostics; }, + get pdfSelectionBoundaryUpdate() { return pdfSelectionBoundaryUpdate; }, set pdfSelectionBoundaryUpdate(value) { pdfSelectionBoundaryUpdate = value; }, + get pdfSelectionPointer() { return pdfSelectionPointer; }, set pdfSelectionPointer(value) { pdfSelectionPointer = value; }, + get registerReaderFormatAdapters() { return registerReaderFormatAdapters; }, set registerReaderFormatAdapters(value) { registerReaderFormatAdapters = value; }, + get renderHistory() { return renderHistory; }, set renderHistory(value) { renderHistory = value; }, + get restoreProgressState() { return restoreProgressState; }, set restoreProgressState(value) { restoreProgressState = value; }, + get returnNavigationToken() { return returnNavigationToken; }, set returnNavigationToken(value) { returnNavigationToken = value; }, + get returnNeedsReload() { return returnNeedsReload; }, set returnNeedsReload(value) { returnNeedsReload = value; }, + get returnUrl() { return returnUrl; }, set returnUrl(value) { returnUrl = value; }, + get seekProgress() { return seekProgress; }, set seekProgress(value) { seekProgress = value; }, + get setPanelSearchOpen() { return setPanelSearchOpen; }, set setPanelSearchOpen(value) { setPanelSearchOpen = value; }, + get start() { return start; }, set start(value) { start = value; }, + get startReaderDownload() { return startReaderDownload; }, set startReaderDownload(value) { startReaderDownload = value; }, + get stopPdfManifestPrefetch() { return stopPdfManifestPrefetch; }, set stopPdfManifestPrefetch(value) { stopPdfManifestPrefetch = value; }, + get stopReaderWork() { return stopReaderWork; }, set stopReaderWork(value) { stopReaderWork = value; }, + get syncTocTab() { return syncTocTab; }, set syncTocTab(value) { syncTocTab = value; }, + get turnViewport() { return turnViewport; }, set turnViewport(value) { turnViewport = value; }, + get updateReadingProgressSummary() { return updateReadingProgressSummary; }, set updateReadingProgressSummary(value) { updateReadingProgressSummary = value; }, + get appendShellsBatched() { return appendShellsBatched; }, set appendShellsBatched(value) { appendShellsBatched = value; }, + get applyReaderPathMetadata() { return applyReaderPathMetadata; }, set applyReaderPathMetadata(value) { applyReaderPathMetadata = value; }, + get bookmarkExcerpt() { return bookmarkExcerpt; }, set bookmarkExcerpt(value) { bookmarkExcerpt = value; }, + get buildDiscourseDocument() { return buildDiscourseDocument; }, set buildDiscourseDocument(value) { buildDiscourseDocument = value; }, + get buildHtmlFrameSource() { return buildHtmlFrameSource; }, set buildHtmlFrameSource(value) { buildHtmlFrameSource = value; }, + get cachePdfOcrPage() { return cachePdfOcrPage; }, set cachePdfOcrPage(value) { cachePdfOcrPage = value; }, + get cachePdfSearchText() { return cachePdfSearchText; }, set cachePdfSearchText(value) { cachePdfSearchText = value; }, + get cachePdfTextContent() { return cachePdfTextContent; }, set cachePdfTextContent(value) { cachePdfTextContent = value; }, + get cachedPdfOcrPage() { return cachedPdfOcrPage; }, set cachedPdfOcrPage(value) { cachedPdfOcrPage = value; }, + get cachedPdfTextContent() { return cachedPdfTextContent; }, set cachedPdfTextContent(value) { cachedPdfTextContent = value; }, + get canPrefetchPdfImages() { return canPrefetchPdfImages; }, set canPrefetchPdfImages(value) { canPrefetchPdfImages = value; }, + get cancelPdfManifestPrefetch() { return cancelPdfManifestPrefetch; }, set cancelPdfManifestPrefetch(value) { cancelPdfManifestPrefetch = value; }, + get chapterSearchModule() { return chapterSearchModule; }, set chapterSearchModule(value) { chapterSearchModule = value; }, + get cleanTocLabel() { return cleanTocLabel; }, set cleanTocLabel(value) { cleanTocLabel = value; }, + get cloneFoliateDocument() { return cloneFoliateDocument; }, set cloneFoliateDocument(value) { cloneFoliateDocument = value; }, + get createFoliateSection() { return createFoliateSection; }, set createFoliateSection(value) { createFoliateSection = value; }, + get createHeadingLocationCache() { return createHeadingLocationCache; }, set createHeadingLocationCache(value) { createHeadingLocationCache = value; }, + get createPdfShellWindow() { return createPdfShellWindow; }, set createPdfShellWindow(value) { createPdfShellWindow = value; }, + get createTocRow() { return createTocRow; }, set createTocRow(value) { createTocRow = value; }, + get detectHtmlEncoding() { return detectHtmlEncoding; }, set detectHtmlEncoding(value) { detectHtmlEncoding = value; }, + get disposeFormatResources() { return disposeFormatResources; }, set disposeFormatResources(value) { disposeFormatResources = value; }, + get ensurePdfBookSearchClient() { return ensurePdfBookSearchClient; }, set ensurePdfBookSearchClient(value) { ensurePdfBookSearchClient = value; }, + get estimatePdfOcrPageBytes() { return estimatePdfOcrPageBytes; }, set estimatePdfOcrPageBytes(value) { estimatePdfOcrPageBytes = value; }, + get estimatePdfTextContentBytes() { return estimatePdfTextContentBytes; }, set estimatePdfTextContentBytes(value) { estimatePdfTextContentBytes = value; }, + get excerptFromCaret() { return excerptFromCaret; }, set excerptFromCaret(value) { excerptFromCaret = value; }, + get fetchWithReaderTimeout() { return fetchWithReaderTimeout; }, set fetchWithReaderTimeout(value) { fetchWithReaderTimeout = value; }, + get foliateSearchAnchor() { return foliateSearchAnchor; }, set foliateSearchAnchor(value) { foliateSearchAnchor = value; }, + get foliateSectionQuery() { return foliateSectionQuery; }, set foliateSectionQuery(value) { foliateSectionQuery = value; }, + get foliateTocEntries() { return foliateTocEntries; }, set foliateTocEntries(value) { foliateTocEntries = value; }, + get fullSearchFoliateMatches() { return fullSearchFoliateMatches; }, set fullSearchFoliateMatches(value) { fullSearchFoliateMatches = value; }, + get getPdfPersistentTextStore() { return getPdfPersistentTextStore; }, set getPdfPersistentTextStore(value) { getPdfPersistentTextStore = value; }, + get highlightPanelItem() { return highlightPanelItem; }, set highlightPanelItem(value) { highlightPanelItem = value; }, + get loadPdfEngine() { return loadPdfEngine; }, set loadPdfEngine(value) { loadPdfEngine = value; }, + get loadPdfSearchText() { return loadPdfSearchText; }, set loadPdfSearchText(value) { loadPdfSearchText = value; }, + get loadPdfTaskWithTimeout() { return loadPdfTaskWithTimeout; }, set loadPdfTaskWithTimeout(value) { loadPdfTaskWithTimeout = value; }, + get loadPdfWithTimeout() { return loadPdfWithTimeout; }, set loadPdfWithTimeout(value) { loadPdfWithTimeout = value; }, + get makePdfSearchPageLoader() { return makePdfSearchPageLoader; }, set makePdfSearchPageLoader(value) { makePdfSearchPageLoader = value; }, + get memoizeDocxAssets() { return memoizeDocxAssets; }, set memoizeDocxAssets(value) { memoizeDocxAssets = value; }, + get memoizeDocxResource() { return memoizeDocxResource; }, set memoizeDocxResource(value) { memoizeDocxResource = value; }, + get normalizeEpubDocument() { return normalizeEpubDocument; }, set normalizeEpubDocument(value) { normalizeEpubDocument = value; }, + get notifyReaderUiState() { return notifyReaderUiState; }, set notifyReaderUiState(value) { notifyReaderUiState = value; }, + get pdfManifestPrefetchConcurrency() { return pdfManifestPrefetchConcurrency; }, set pdfManifestPrefetchConcurrency(value) { pdfManifestPrefetchConcurrency = value; }, + get pdfManifestPrefetchMaxPages() { return pdfManifestPrefetchMaxPages; }, set pdfManifestPrefetchMaxPages(value) { pdfManifestPrefetchMaxPages = value; }, + get pdfManifestSource() { return pdfManifestSource; }, set pdfManifestSource(value) { pdfManifestSource = value; }, + get pdfPixelBudget() { return pdfPixelBudget; }, set pdfPixelBudget(value) { pdfPixelBudget = value; }, + get pdfRetainedPixels() { return pdfRetainedPixels; }, set pdfRetainedPixels(value) { pdfRetainedPixels = value; }, + get pdfStage() { return pdfStage; }, set pdfStage(value) { pdfStage = value; }, + get pdfSurfacePixels() { return pdfSurfacePixels; }, set pdfSurfacePixels(value) { pdfSurfacePixels = value; }, + get placeReadingProgress() { return placeReadingProgress; }, set placeReadingProgress(value) { placeReadingProgress = value; }, + get prefetchPdfBookSearch() { return prefetchPdfBookSearch; }, set prefetchPdfBookSearch(value) { prefetchPdfBookSearch = value; }, + get preloadPdfFirstPage() { return preloadPdfFirstPage; }, set preloadPdfFirstPage(value) { preloadPdfFirstPage = value; }, + get prepareHtmlDocument() { return prepareHtmlDocument; }, set prepareHtmlDocument(value) { prepareHtmlDocument = value; }, + get preparePdfWorker() { return preparePdfWorker; }, set preparePdfWorker(value) { preparePdfWorker = value; }, + get preparePdfWorkerAttempt() { return preparePdfWorkerAttempt; }, set preparePdfWorkerAttempt(value) { preparePdfWorkerAttempt = value; }, + get publishPdfSearchProgress() { return publishPdfSearchProgress; }, set publishPdfSearchProgress(value) { publishPdfSearchProgress = value; }, + get publishSearchResults() { return publishSearchResults; }, set publishSearchResults(value) { publishSearchResults = value; }, + get readPdfOcrJson() { return readPdfOcrJson; }, set readPdfOcrJson(value) { readPdfOcrJson = value; }, + get readerAssetObjectFamily() { return readerAssetObjectFamily; }, set readerAssetObjectFamily(value) { readerAssetObjectFamily = value; }, + get refreshFoliateWindow() { return refreshFoliateWindow; }, set refreshFoliateWindow(value) { refreshFoliateWindow = value; }, + get renderHybridPdfCanvas() { return renderHybridPdfCanvas; }, set renderHybridPdfCanvas(value) { renderHybridPdfCanvas = value; }, + get renderPdfImageShell() { return renderPdfImageShell; }, set renderPdfImageShell(value) { renderPdfImageShell = value; }, + get renderVirtualToc() { return renderVirtualToc; }, set renderVirtualToc(value) { renderVirtualToc = value; }, + get repairHtmlContrast() { return repairHtmlContrast; }, set repairHtmlContrast(value) { repairHtmlContrast = value; }, + get resolveReaderSource() { return resolveReaderSource; }, set resolveReaderSource(value) { resolveReaderSource = value; }, + get restoreInitialPosition() { return restoreInitialPosition; }, set restoreInitialPosition(value) { restoreInitialPosition = value; }, + get rewriteFoliateResources() { return rewriteFoliateResources; }, set rewriteFoliateResources(value) { rewriteFoliateResources = value; }, + get sanitizeOfflineHtml() { return sanitizeOfflineHtml; }, set sanitizeOfflineHtml(value) { sanitizeOfflineHtml = value; }, + get sanitizeReaderDocument() { return sanitizeReaderDocument; }, set sanitizeReaderDocument(value) { sanitizeReaderDocument = value; }, + get scheduleFoliateWindowRefresh() { return scheduleFoliateWindowRefresh; }, set scheduleFoliateWindowRefresh(value) { scheduleFoliateWindowRefresh = value; }, + get searchChapterBook() { return searchChapterBook; }, set searchChapterBook(value) { searchChapterBook = value; }, + get setBookmarkDialogModal() { return setBookmarkDialogModal; }, set setBookmarkDialogModal(value) { setBookmarkDialogModal = value; }, + get setupFoliateWindow() { return setupFoliateWindow; }, set setupFoliateWindow(value) { setupFoliateWindow = value; }, + get trimFoliateSections() { return trimFoliateSections; }, set trimFoliateSections(value) { trimFoliateSections = value; }, + get trimPdfManifestPrefetches() { return trimPdfManifestPrefetches; }, set trimPdfManifestPrefetches(value) { trimPdfManifestPrefetches = value; }, + get trimPdfSurfaces() { return trimPdfSurfaces; }, set trimPdfSurfaces(value) { trimPdfSurfaces = value; }, + get waitForSpreadsheetImages() { return waitForSpreadsheetImages; }, set waitForSpreadsheetImages(value) { waitForSpreadsheetImages = value; } +}; +const readerBindings = createReaderBindings(readerContext); + +let { + pdfStage, measurePdf +} = createReaderDiagnostics(readerContext); + +let { + readerAbortError, assertReaderActive, trackReaderResource, stopReaderWork, + awaitReader, waitForReader, setReaderPhase, setReaderStage, + classifyReaderError, markReaderContentReady, nextReaderGeneration, isReaderGenerationCurrent, + reportNavigationError, disposeReader, fail +} = createReaderLifecycle(readerContext); + +let { + readerContentUrl, readerDownloadApiUrl, readerDownloadUrl, startReaderDownload, + normalizeSourceUrl, resolveReaderId, applyReaderMetadata, applyReaderPathMetadata, + validSource, validOcr, validFallback, readerAssetObjectFamily, + trustedReaderAssetManifestUrl, trustedChapterUrl, trustedChapterReferenceUrl, trustedChapterResourceUrl +} = createReaderSource(readerContext); + +let { + showDownloadFeedback, updateDocumentState, updatePanelState, applyPageInversion, + applyReaderTheme, setZoom, emptyPanel, highlightPanelItem, + filterPanel, notifyReaderUiState, setReaderPanelOpen, setPanelSearchOpen, + selectPanel, renderHistory +} = createReaderUi(readerContext); + +let { + updateNavigationState, headingTocEntries, createHeadingLocationCache, syncHeadingLocation, + navigateTocEntry, createTocRow, renderVirtualToc, setToc, + cleanTocLabel, updateTocCurrentMark, syncTocTab +} = createReaderToc(readerContext); + +let { + updateSearchState, publishSearchResults, clearSearchHighlights, chapterSearchModule, + searchChapterBook, navigateChapterSearchResult, fullSearchFoliateMatches, navigateFoliateSearchResult, + searchReaderText +} = createReaderSearchBackends(readerContext); + +let { + pdfManifestPrefetchConcurrency, pdfManifestPrefetchMaxPages, canPrefetchPdfImages, cancelPdfManifestPrefetch, + stopPdfManifestPrefetch, trimPdfManifestPrefetches, pdfPixelBudget, pdfSurfacePixels, + pdfRetainedPixels, trimPdfSurfaces, clearPdfManifestPrefetches, takePdfManifestPrefetch, + queuePdfManifestPrefetch, prioritizePdfManifestPrefetch, schedulePdfManifestPrefetch, trimPdfManifestImages, + cancelSpeculativePdfRenders, promotePdfRenderWaiter, acquirePdfRenderSlot, releasePdfRenderSlot, + acquirePdfTextSlot, drainPdfTextWaiters, releasePdfTextSlot, trimPdfCanvases, + rerenderVisiblePdfPages, refreshNearbyPdfPages +} = createReaderPdfScheduler(readerContext); + +let { + beginReaderNavigation, clearReturnNavigation, navigateReaderFolder, turnViewport, + handleReaderKeydown, readerPageScrollTop, goToPage, handleReaderPositionChange, + navigateReader, notePdfScrollIntent, installReaderTapSelectionClear +} = createReaderNavigation(readerContext); + +let { + foliateSectionRoot, foliateSectionQuery, foliateSectionCandidates, syncFoliateScrollLocation, + refreshFoliateWindow, scheduleFoliateWindowRefresh, trimFoliateSections, scheduleFoliateScrollSync, + activateFoliateTocEntry, cloneFoliateDocument, foliateSearchAnchor, settleReaderImages, + rewriteFoliateResources, createFoliateSection, setupFoliateWindow, foliateTocEntries, + renderFoliate +} = createReaderFoliateFormat(readerContext); + +let { + scheduleSave, saveProgress, placeReadingProgress, currentReaderUrl, + loadReaderRestoration, getInitialReaderRestoration, initialReaderPage, readerProgressPercent, + pageAtMarker, syncCurrentPageFromMarker, scheduleMarkerSync, restoreFormat, + restoreInitialPosition, applyInitialRestoration, updateReadingProgressSummary, updateProgressTools, + captureProgressState, restoreProgressState, flushEpubSeek, seekProgress, + activateChapter, adjacentTocIndex +} = createReaderProgress(readerContext); + +let { + consumeBookmarkHandoff, excerptFromCaret, bookmarkExcerpt, captureFoliateBookmarkPosition, + restoreFoliateBookmarkPosition, seekFoliateProgress, renderBookmarks, captureBookmarkSnapshot, + setBookmarkDialogModal, openBookmarkPopover, closeBookmarkPopover +} = createReaderBookmarks(readerContext); + +let { + formatMediaTime, renderImageDocument, renderMedia, loadSwfDocument, + renderSwf +} = createReaderMediaFormat(readerContext); + +let { + loadScript, fetchWithReaderTimeout, retryableReaderProxyError, retryReaderProxy, + fetchReaderUrl, fetchReaderResponse, prepareReaderDocument, readPreparedBytes, + loadTextDocument, loadMarkdownDocument, loadHtmlDocument, loadDocxDocument, + loadMediaDocument, loadImageDocument, loadChapterManifestDocument +} = createReaderDocumentLoader(readerContext); + +let { + preloadPdfFirstPage, pdfManifestSource, pdfPageEntry, appendShellsBatched, + pdfSelectionIntersects, correctPdfCrossPageSelection, createPdfShellWindow, renderPdfPages, + renderImagePages, renderPdf +} = createReaderPdfPageWindow(readerContext); + +let { + pdfOcrSource, v3ResourceUrl, createPdfV3Repository, readPdfOcrJson, + loadPdfOcrManifest, loadPdfOcrPage, estimatePdfTextContentBytes, cachePdfTextContent, + cachedPdfTextContent, loadPdfTextContent, estimatePdfOcrPageBytes, cachePdfOcrPage, + cachedPdfOcrPage, renderPdfOcrText, renderPdfText, highlightPdfText +} = createReaderPdfTextRuntime(readerContext); + +let { + normalizeEpubDocument, sanitizeReaderDocument, sanitizeEpubDocument, parseChapterDocument, + epubContentBody, renderText, sanitizeOfflineHtml, buildDiscourseDocument, + prepareHtmlDocument, buildHtmlFrameSource, waitForSpreadsheetImages, renderHtml, + detectHtmlEncoding, repairHtmlContrast +} = createReaderHtmlFormat(readerContext); + +let { + setPdfPageLoadingState, isPdfPageVisible, pdfPagePriority, normalizePdfPriority, + renderPdfInBackground, pdfPrimarySource, renderPdfManifestShell, renderHybridPdfCanvas, + renderPdfImageShell, renderPdfShell +} = createReaderPdfRenderer(readerContext); + +let { + renderPlainText, detectTextEncoding, renderTextDocument, renderMarkdownDocument +} = createReaderTextFormat(readerContext); + +let { + renderChapterManifest, captureChapterPosition, restoreChapterPosition +} = createReaderChapterFormat(readerContext); + +let { + renderDocx, memoizeDocxResource, memoizeDocxAssets +} = createReaderDocxFormat(readerContext); + +let { + disposeFormatResources, registerReaderFormatAdapters, resolveReaderSource, start, + syncCapabilityControls +} = createReaderFormatController(readerContext); + +let { + loadPdfTaskWithTimeout, loadPdfWithTimeout, preloadPdfEngine, preparePdfWorker, + preparePdfWorkerAttempt, loadPdfEngine, loadPdfDocument +} = createReaderPdfEngine(readerContext); + +let { + ensurePdfBookSearchClient, prefetchPdfBookSearch, schedulePdfBookSearchPrefetch, searchConcentratedPdf, + makePdfSearchPageLoader, publishPdfSearchProgress, getPdfPersistentTextStore, cachePdfSearchText, + loadPdfSearchText, fullSearchPdfMatches +} = createReaderPdfSearch(readerContext); + let pdfFetchPolicy = null; const pdfPageTimings = []; -function pdfStage(stage, page = 0) { - const started = performance.now(); - return (outcome = "ready") => { - if (readerAbortController.signal.aborted) return; - pdfPageTimings.push({at:started, stage, page, outcome, ms:performance.now()-started}); - if (pdfPageTimings.length > 64) pdfPageTimings.shift(); - }; -} -function measurePdf(promise, stage, page = 0) { - const finish = pdfStage(stage,page); - return Promise.resolve(promise).then(value => { finish(); return value; }, error => { - finish(error?.name === "AbortError" ? "cancelled" : "failed"); throw error; - }); -} + // Engines and Reader lifecycle. const PDFJS_URL = VoiceOfMLReaderResources.vendorUrl("pdf", "/static/"); -const PDFJS_WORKER_URL = "/static/pdf-worker-wrapper.mjs"; +const PDFJS_WORKER_URL = "/static/reader/pdf/pdf-worker-wrapper.mjs"; const PDFJS_WASM_URL = "/static/vendor/wasm/"; const PDFJS_CMAP_URL = "/static/vendor/cmaps/"; const PDFJS_STANDARD_FONT_URL = "/static/vendor/standard_fonts/"; @@ -44,163 +702,25 @@ const READER_PROXY_TIMEOUT_MS = 120000; const readerRequestManager = VoiceOfMLReaderRequests.createReaderRequestManager(); const readerRuntime = VoiceOfMLReaderRuntime.createReaderRuntime(); const formatAdapters = VoiceOfMLReaderAdapters.createAdapterRegistry(); -readerRuntime.track(readerRequestManager); -readerRuntime.track(formatAdapters); -readerRuntime.track(window.__VOICE_PDF_PRELOAD__); -readerRuntime.track(window.__VOICE_READER_CHAPTER_PRELOAD__); +readerBindings.trackStartupOwners(); const readerAbortController = new AbortController(); const readerResources = new Set(); -function readerAbortError() { - return new DOMException("Reader work cancelled", "AbortError"); -} -function assertReaderActive() { - if (readerAbortController.signal.aborted || readerRuntime.state.lifecycle.disposed) - throw readerAbortError(); -} -function trackReaderResource(cleanup) { - if (readerAbortController.signal.aborted) cleanup(); - else readerResources.add(cleanup); - return () => readerResources.delete(cleanup); -} -function stopReaderWork() { - readerAbortController.abort(); - for (const cleanup of readerResources) { - try { - cleanup(); - } catch (_) {} - } - readerResources.clear(); -} -readerRuntime.track(stopReaderWork); -trackReaderResource(() => window.__VOICE_READER_RESOLVE__?.dispose()); -const pdfDiagnostics = Object.freeze({snapshot:() => ({pages:pdfPageTimings.map(row=>({...row})), requests:pdfFetchPolicy?.timings || [], cache:pdfPersistentTextStore?.stats || null})}); -window.VoiceOfMLReaderPdfDiagnostics = pdfDiagnostics; -trackReaderResource(() => { - pdfPageTimings.length = 0; - if (window.VoiceOfMLReaderPdfDiagnostics === pdfDiagnostics) delete window.VoiceOfMLReaderPdfDiagnostics; + +readerBindings.trackWorkCancellation(); +const imageLoader = createReaderImageLoader({ + assertActive: assertReaderActive, abortError: readerAbortError, trackResource: trackReaderResource, + request: fetchWithReaderTimeout, readBytes: response => VoiceOfMLReaderSecurity.readBytes(response), + timeoutMs: READER_PROXY_TIMEOUT_MS }); -function awaitReader(promise, extraSignal = null) { - return new Promise((resolve, reject) => { - let settled = false; - const signals = extraSignal ? [readerAbortController.signal, extraSignal] : [readerAbortController.signal]; - const finish = (callback, value) => { - if (settled) return; - settled = true; - signals.forEach(signal => signal.removeEventListener("abort", abort)); - callback(value); - }; - const abort = () => finish(reject, readerAbortError()); - Promise.resolve(promise) - .then(value => finish(resolve, value), error => finish(reject, error)); - if (signals.some(signal => signal.aborted)) abort(); - else signals.forEach(signal => signal.addEventListener("abort", abort, { once: true })); - }); -} -function waitForReader(delay = 0, animationFrame = false) { - return new Promise((resolve, reject) => { - assertReaderActive(); - const signal = readerAbortController.signal; - const abort = () => { - if (animationFrame) cancelAnimationFrame(handle); - else clearTimeout(handle); - reject(readerAbortError()); - }; - const done = () => { - signal.removeEventListener("abort", abort); - resolve(); - }; - const handle = animationFrame ? requestAnimationFrame(done) : setTimeout(done, delay); - signal.addEventListener("abort", abort, { once: true }); - }); -} -// Polyfills for vendor engines (pdf.js, foliate): neither helper has a direct -// caller in first-party code, but both are required by pinned vendor bundles. -if (!Map.prototype.getOrInsertComputed) { - Map.prototype.getOrInsertComputed = function (key, callback) { - if (this.has(key)) return this.get(key); - const value = callback(key); - this.set(key, value); - return value; - }; -} -if (!Math.sumPrecise) { - Math.sumPrecise = function (values) { - let sum = 0; - let correction = 0; - for (const value of values) { - const next = sum + value; - correction += Math.abs(sum) >= Math.abs(value) ? sum - next + value : value - next + sum; - sum = next; - } - return sum + correction; - }; -} +readerBindings.trackEarlySourceResolution(); +const pdfDiagnostics = Object.freeze({snapshot:() => ({pages:pdfPageTimings.map(row=>({...row})), requests:pdfFetchPolicy?.timings || [], cache:pdfPersistentTextStore?.stats || null})}); +readerBindings.publishDiagnosticsAndInstallCompatibility(); // Source metadata and session navigation. -function readerContentUrl(url) { - try { - const parsed = new URL(url, location.href); - if (parsed.origin === location.origin && parsed.pathname === "/api/reader-bucket-resource") - return parsed.href; - if (parsed.origin === "https://huggingface.co" && !parsed.username && !parsed.password && !parsed.search && !parsed.hash) { - const prefix = /^\/buckets\/(vomebook\/(?:pdf-pages-v2|reader-assets-v2))\/resolve\//.exec(parsed.pathname); - const path = prefix && decodeURIComponent(parsed.pathname.slice(prefix[0].length)); - if (path && VoiceOfMLReader.isBucketPath(path, true, prefix[1])) - return `/api/reader-bucket-resource?path=${encodeURIComponent(path)}`; - } - } catch (_) {} - return `/api/reader-content?url=${encodeURIComponent(url)}`; -} -function readerDownloadApiUrl(path) { - return path; -} -function readerDownloadUrl(filename, link) { - return readerDownloadApiUrl(`/api/download?file=${encodeURIComponent(filename || "file")}&link=${encodeURIComponent(link || "")}`); -} -async function startReaderDownload(event) { - event.preventDefault(); - const button = event.currentTarget; - if (button.hasAttribute("aria-busy")) return; - showDownloadFeedback("正在准备下载…", 0); - const controller = new AbortController(); - let timer; - button.setAttribute("aria-busy", "true"); - try { - // ID-only links share the Reader's in-flight resolution, not its render phase. - if (!downloadUrl) await readerSourcePromise; - if (!downloadUrl) throw new Error("无法获取原文件地址"); - const filename = documentState.title || "file"; - const checkUrl = readerDownloadApiUrl(`/api/download/check?link=${encodeURIComponent(downloadUrl)}`); - timer = setTimeout(() => controller.abort(), 8000); - const response = await fetch(checkUrl, { signal: controller.signal, cache: "no-store" }); - const result = await response.json().catch(() => null); - if (!response.ok || result?.ok !== true) throw new Error(result?.error || "下载检查失败,请重试"); - if (readerLifecycle.disposed) return; - const frame = document.createElement("iframe"); - frame.hidden = true; - frame.setAttribute("aria-hidden", "true"); - frame.src = readerDownloadUrl(filename, downloadUrl); - document.body.appendChild(frame); - setTimeout(() => frame.remove(), 60000); - showDownloadFeedback("已发起下载"); - } catch (error) { - if (!readerLifecycle.disposed) { - showDownloadFeedback(error?.name === "AbortError" ? "下载检查超时,请重试" : (error.message || "下载失败,请重试")); - } - } finally { - clearTimeout(timer); - button.removeAttribute("aria-busy"); - } -} -function normalizeSourceUrl(url) { - return url.startsWith("/api/reader-bucket-resource?") ? new URL(url, location.origin).href : url; -} + const params = new URLSearchParams(location.search); const readerId = params.get("id") || ""; let localReaderData = null; -try { - localReaderData = JSON.parse(sessionStorage.getItem(`reader-source:${readerId}`) || "null"); - if (localReaderData) sessionStorage.removeItem(`reader-source:${readerId}`); -} catch (_) {} +readerBindings.readSelectedSourceSession(); let sourceUrl = normalizeSourceUrl( params.get("url") || (!readerId && localReaderData && localReaderData.url) || "" ); @@ -210,14 +730,7 @@ let downloadUrl = let extension = (params.get("ext") || "").toLowerCase(); let chapterManifestUrl = params.get("chapter_manifest") || ""; let capability = readerRuntime.negotiate(VoiceOfMLReader.capability(extension)); -if ( - extension === "pdf" && - /\/api\/reader-bucket-resource\?/.test(sourceUrl) && - new URL(sourceUrl, location.href).searchParams.get("path")?.endsWith("page-manifest.json") -) { - extension = "pdf-pages"; - capability = readerRuntime.negotiate(VoiceOfMLReader.capability(extension)); -} +readerBindings.normalizeConvertedPdfMode(); let resolvedReaderData = localReaderData; let cachedReaderData = null; let pdfFirstPagePreload = null; @@ -228,131 +741,16 @@ let pdfHybridDocumentPromise = null; let pdfHybridPreviewLoads = 0; let pdfWorker = null; let releasePdfWorker = null; -if (localReaderData) { - if (localReaderData.title) params.set("title", localReaderData.title); - if (localReaderData.extension) params.set("ext", localReaderData.extension); - if (localReaderData.repo) - params.set("path", [localReaderData.repo, ...(localReaderData.folder || [])].join("/")); -} -try { - const cached = sessionStorage.getItem(`reader-resolve:${readerId}`); - if (cached) { - cachedReaderData = JSON.parse(cached); - resolvedReaderData = cachedReaderData; - sessionStorage.removeItem(`reader-resolve:${readerId}`); - } -} catch (_) {} +readerBindings.applySourceSessionAndCachedMetadata(); const readerLifecycle = readerRuntime.state.lifecycle; -readerRuntime.update("source", { - id: readerId, - url: sourceUrl, - contentUrl, - downloadUrl, - extension, - metadata: resolvedReaderData -}); -readerRuntime.events.on("phase", ({ phase }) => { - document.documentElement.dataset.readerPhase = phase; -}); -readerRuntime.track( - VoiceOfMLReaderStore.subscribe?.((change) => { - if (!change?.remote || readerLifecycle.disposed) return; - if ( - change.type === "history-clear" || - (change.type === "history-remove" && change.url === sourceUrl) - ) { - historySuppressed = true; - readerRuntime.cancel(saveTimer); - saveTimer = saveDeadline = 0; - } - if ( - ["history", "history-clear", "history-remove"].includes(change.type) && - !document.querySelector("#history-panel").hidden - ) - renderHistory(); - if ( - ["bookmark", "bookmark-remove"].includes(change.type) && - (panelState.showingAllBookmarks || !change.url || change.url === sourceUrl) && - !document.querySelector("#bookmarks-panel").hidden - ) - renderBookmarks(); - }) -); -function setReaderPhase(phase) { - return readerRuntime.setPhase(phase); -} -function setReaderStage(stage) { - return readerRuntime.setStage(stage); -} -async function resolveReaderId(id) { - const endpoint = `/api/reader-resolve?id=${encodeURIComponent(id)}`; - for (let attempt = 0; attempt < 2; attempt++) { - const early = window.__VOICE_READER_RESOLVE__; - if (!attempt && early?.id === id) { - window.__VOICE_READER_RESOLVE__ = null; - const untrack = trackReaderResource(early.dispose); - let result; - try { result = await awaitReader(early.promise); } - finally { untrack(); early.dispose(); } - if (result.ok) return result.data; - if (![408, 429, 500, 502, 503, 504].includes(result.status)) - throw new Error(`HTTP ${result.status}`); - await waitForReader(350); - continue; - } - const response = await readerRequestManager.request(endpoint, READER_PROXY_TIMEOUT_MS); - if (response.ok) return response.json(); - if (![408, 429, 500, 502, 503, 504].includes(response.status) || attempt) - throw new Error(`HTTP ${response.status}`); - await response.body?.cancel(); - await waitForReader(350); - } - throw new Error("Reader ID resolution failed"); -} -function classifyReaderError(error, fallback = "READER_PARSE") { - if (["READER_ENGINE_NETWORK", "READER_RESTORE"].includes(error?.code)) return error.code; - const value = `${error?.name || ""} ${error?.message || error || ""}`; - if (/AbortError|timeout|network|fetch|HTTP\s*\d+/i.test(value)) return "READER_NETWORK"; - if ( - /EPUB_INVALID|corrupt|damage|truncated|central directory|end of data|invalid zip/i.test(value) - ) - return "READER_CORRUPT"; - return fallback; -} -setReaderPhase("startup"); -window.fetchFile = async (url) => { - const requestUrl = String(url) === sourceUrl ? contentUrl : url; - const response = await readerRequestManager.request(requestUrl, READER_PROXY_TIMEOUT_MS); - if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); - const fileName = new URL(url, location.href).pathname.split("/").pop() || "book"; - return new File( - [ - await VoiceOfMLReaderSecurity.readBytes( - response, - VoiceOfMLReaderSecurity.LIMITS.archiveCompressedBytes - ) - ], - fileName, - { type: response.headers.get("content-type") || "application/octet-stream" } - ); -}; +readerBindings.publishSourceAndSubscribeToStore(); const content = document.querySelector("#content"); -document.querySelector(".reader-panel-tabs")?.setAttribute("role", "tablist"); +readerBindings.initializePanelAccessibility(); const loadingIndicator = document.createElement("div"); -loadingIndicator.className = "reader-loading-indicator"; -loadingIndicator.setAttribute("role", "status"); -loadingIndicator.innerHTML = - '正在加载正文...'; -content.appendChild(loadingIndicator); -content.dataset.mode = capability.mode || "unsupported"; +readerBindings.mountInitialLoadingFeedback(); const status = document.querySelector("#status"); const loadingStatus = document.querySelector("#loading-status"); -function markReaderContentReady() { - if (["foliate", "epub-chapters"].includes(capability?.mode) && loadingIndicator.isConnected) - foliateScrollAnchors.preserve(() => loadingIndicator.remove()); - else loadingIndicator.remove(); - loadingStatus.hidden = true; -} + const downloadButton = document.querySelector("#download"); const downloadFeedback = document.querySelector("#download-feedback"); let readerSourcePromise = null; @@ -360,14 +758,8 @@ let readerRestorationPromise = null; let readerRestorationEntry = null; let readerRestorationLoaded = false; let downloadFeedbackTimer = 0; -function showDownloadFeedback(message, duration = 4000) { - clearTimeout(downloadFeedbackTimer); - downloadFeedback.textContent = message; - downloadFeedback.hidden = !message; - if (message && duration) downloadFeedbackTimer = setTimeout(() => showDownloadFeedback(""), duration); -} -downloadButton.addEventListener("click", startReaderDownload); -loadingStatus.textContent = ""; + +readerBindings.bindDownloadAction(); const sourceName = (() => { try { const name = decodeURIComponent( @@ -384,47 +776,9 @@ const documentState = readerRuntime.state.document, navigationState = readerRuntime.state.navigation, searchState = readerRuntime.state.search, panelState = readerRuntime.state.panel; -function updateDocumentState(patch) { - return readerRuntime.update("document", patch); -} -function updateNavigationState(patch) { - return readerRuntime.update("navigation", patch); -} -function updateSearchState(patch) { - return readerRuntime.update("search", patch); -} -function publishSearchResults(generation, results) { - return isReaderGenerationCurrent("search", generation) && updateSearchState({ results }); -} -function updatePanelState(patch) { - return readerRuntime.update("panel", patch); -} -updateDocumentState({ - title: - String( - params.get("title") || (localReaderData && localReaderData.title) || sourceName - ).trim() || sourceName, - zoom: Math.min(4, Math.max(0.25, Number(localStorage.getItem("reader-zoom") || 100) / 100)) -}); -function applyReaderMetadata(data) { - if (data) resolvedReaderData = { ...(resolvedReaderData || {}), ...data }; - if (data?.ReaderOcrManifest || data?.ocr_manifest) { - ocrManifestUrl = data.ReaderOcrManifest || data.ocr_manifest; - params.set("ocr_manifest", ocrManifestUrl); - } - readerRuntime.update("source", { metadata: resolvedReaderData }); - const title = String(resolvedReaderData?.title || params.get("title") || sourceName).trim(); - const suffix = String( - resolvedReaderData?.original_extension || (extension === "pdf-pages" ? "pdf" : extension) - ) - .toLowerCase() - .replace(/^\./, ""); - const value = title + (suffix && !title.toLowerCase().endsWith(`.${suffix}`) ? `.${suffix}` : ""); - updateDocumentState({ title: value }); - document.querySelector("#title").textContent = value; - document.title = `${value} - VoiceOfML Reader`; - applyReaderPathMetadata(resolvedReaderData); -} + +readerBindings.initializeDocumentTitle(); + let fallbackUrl = params.get("fallback") || ""; const ocrUrl = params.get("ocr") || ""; let ocrManifestUrl = params.get("ocr_manifest") || ""; @@ -433,38 +787,10 @@ const readerPathLabel = params.get("path") || ""; const folderReturnUrl = params.get("folder_url") || ""; let returnNavigationToken = params.get("nav") || ""; let returnNeedsReload = false; -try { - const state = history.state; - const saved = JSON.parse(sessionStorage.getItem("reader-navigation-current") || "null"); - let stateUrl = - state && state.voiceReaderOverlay && state.readerUrl - ? new URL(state.readerUrl, location.origin) - : null; - if (!stateUrl && saved && saved.shareUrl === location.href && saved.readerUrl) - stateUrl = new URL(saved.readerUrl, location.origin); - const cleanStateUrl = stateUrl && new URL(stateUrl.href); - if (cleanStateUrl) { - cleanStateUrl.searchParams.delete("return"); - cleanStateUrl.searchParams.delete("nav"); - } - if ( - stateUrl && - stateUrl.origin === location.origin && - stateUrl.pathname === "/static/reader.html" && - cleanStateUrl.href === location.href - ) { - if (!returnUrl) returnUrl = stateUrl.searchParams.get("return") || ""; - if (!returnNavigationToken) returnNavigationToken = stateUrl.searchParams.get("nav") || ""; - returnNeedsReload = true; - } -} catch (_) {} +readerBindings.restoreReturnNavigation(); const returnHistoryKey = returnNavigationToken ? `reader-return:${returnNavigationToken}` : ""; let canReturnWithHistory = false; -try { - const target = new URL(returnUrl, location.origin); - const storedReturnUrl = returnHistoryKey ? sessionStorage.getItem(returnHistoryKey) : ""; - canReturnWithHistory = !!returnHistoryKey && storedReturnUrl === target.href; -} catch (_) {} +readerBindings.validateReturnHistory(); let saveTimer = 0; let saveDeadline = 0; let chapterManifestLoader = null; @@ -472,25 +798,19 @@ let chapterSearchConfiguration = null; let chapterSearchModulePromise = null; let chapterSearchClient = null; -trackReaderResource(() => { - chapterSearchClient?.dispose(); - chapterSearchClient = null; -}); +readerBindings.ownChapterSearchClient(); let folderNavigationTarget = null; let bookmarkInvoker = null; const bookmarkInertSiblings = new Map(); let pdfDocument = null; let docxPageNodes = []; -trackReaderResource(() => { docxPageNodes = []; }); +readerBindings.ownDocxPageNodes(); let pdfPageManifest = null; let pdfOcrManifest = null; let pdfOcrManifestPromise = null; let pdfV3Manifest = null, pdfV3Map = null, pdfV3Repository = null, pdfTextLayerRepository = null; const pdfV3BlobReleases = new Set(); -trackReaderResource(() => { - for (const release of [...pdfV3BlobReleases]) release(); - pdfV3Repository?.dispose(); pdfTextLayerRepository?.dispose(); -}); +readerBindings.ownIndependentPdfResources(); const pdfOcrPagePromises = new Map(); const pdfTextContentCache = new Map(); const pdfTextContentPromises = new Map(); @@ -506,13 +826,7 @@ let pdfBookSearchPrefetchPromise = null; // Do not silently download large full-book indexes just because a reader opened. const PDF_BOOK_SEARCH_PREFETCH_MAX_BYTES = 8 * 1024 * 1024; const PDF_BOOK_SEARCH_PREFETCH_DELAY = 6000; -trackReaderResource(() => { - pdfBookSearchClient?.dispose(); - pdfBookSearchClient = null; - clearTimeout(pdfBookSearchPrefetchTimer); - pdfBookSearchPrefetchTimer = 0; - pdfBookSearchPrefetchPromise = null; -}); +readerBindings.ownPdfBookSearchClient(); let pdfActiveRenders = 0; let pdfShellsReady = Promise.resolve(); let pdfShellWindow = null; @@ -535,119 +849,8 @@ let pdfManifestPrefetchOrigin = 0; const pdfManifestPrefetchActive = new Set(); let pdfManifestPrefetchCount = 0; let pdfManifestPrefetchWrapped = false; -function pdfManifestPrefetchConcurrency() { - const type = navigator.connection?.effectiveType; - if (type === "slow-2g" || type === "2g") return 1; - if (type === "3g") return 1; - if (matchMedia("(max-width: 700px)").matches) return 2; - return PDF_MANIFEST_PREFETCH_CONCURRENCY; -} -function pdfManifestPrefetchMaxPages() { - const type = navigator.connection?.effectiveType; - if (type === "slow-2g" || type === "2g") return 8; - if (type === "3g") return 16; - return matchMedia("(max-width: 700px)").matches ? 24 : PDF_MANIFEST_PREFETCH_MAX_PAGES; -} -function canPrefetchPdfImages() { - const connection = navigator.connection; - return !connection?.saveData && !["slow-2g", "2g"].includes(connection?.effectiveType); -} -function cancelPdfManifestPrefetch(record) { - if (!record) return; - pdfManifestPrefetchActive.delete(record); - clearTimeout(record.timeout); - if (pdfManifestPrefetches.get(record.url) === record) pdfManifestPrefetches.delete(record.url); - record.image.onload = record.image.onerror = null; - record.image.removeAttribute("src"); -} -function stopPdfManifestPrefetch() { - clearTimeout(pdfManifestPrefetchTimer); - pdfManifestPrefetchTimer = 0; - for (const record of [...pdfManifestPrefetchActive]) cancelPdfManifestPrefetch(record); -} -function trimPdfManifestPrefetches() { - while (pdfManifestPrefetches.size > PDF_MANIFEST_PREFETCH_LIMIT || pdfRetainedPixels() > pdfPixelBudget()) { - const victim = [...pdfManifestPrefetches.values()].find((record) => !pdfManifestPrefetchActive.has(record)); - if (!victim) return; - pdfManifestPrefetches.delete(victim.url); - victim.image.onload = victim.image.onerror = null; - victim.image.removeAttribute("src"); - } -} -function pdfPixelBudget() { - const lowMemory = Number(navigator.deviceMemory) > 0 && Number(navigator.deviceMemory) <= 2; - return (lowMemory ? 32 : matchMedia("(max-width: 700px)").matches ? 48 : 96) * 1024 * 1024 / 4; -} -function pdfSurfacePixels(surface) { - return surface?.tagName === "CANVAS" ? surface.width * surface.height : - (surface?.naturalWidth || 0) * (surface?.naturalHeight || 0); -} -function pdfRetainedPixels() { - let pixels = 0; - for (const surface of content.querySelectorAll(".reader-page > canvas, .reader-page > img")) - pixels += pdfSurfacePixels(surface); - for (const record of pdfManifestPrefetches.values()) pixels += pdfSurfacePixels(record.image); - return pixels; -} -function trimPdfSurfaces(protectedShell) { - trimPdfManifestPrefetches(); - const shells = [...content.querySelectorAll('.reader-page[data-render-state="rendered"]')]; - const canvasLimit = matchMedia("(max-width: 700px)").matches ? 7 : 11; - let canvases = shells.filter(shell => shell.querySelector("canvas.ready")).length; - let images = shells.filter(shell => shell.querySelector("img.ready")).length; - let pixels = pdfRetainedPixels(); - const retentionPage = Number(pageAtMarker()?.dataset.page) || documentState.page; - const distance = shell => { - const delta = Number(shell.dataset.page) - retentionPage; - return Math.abs(delta) * (delta * pdfScrollDirection < 0 ? 2 : 1); - }; - shells.sort((a, b) => distance(b) - distance(a) || - Number(a.dataset.renderUsedAt || 0) - Number(b.dataset.renderUsedAt || 0)); - for (const shell of shells) { - const canvas = shell.querySelector("canvas.ready"), image = shell.querySelector("img.ready"); - if (pixels <= pdfPixelBudget() && !(canvas && canvases > canvasLimit) && !(image && images > 25)) continue; - if (isPdfPageVisible(shell) || Number(shell.dataset.page) === retentionPage || pdfSelectionIntersects(shell)) continue; - shell._textEpoch = (shell._textEpoch || 0) + 1; - if (canvas) { - pixels -= pdfSurfacePixels(canvas); - canvas.width = canvas.height = 0; - canvas.classList.remove("ready"); - canvases--; - } - if (image) { - pixels -= pdfSurfacePixels(image); - image.removeAttribute("src"); - image.remove(); - shell._v3PreviewRelease?.(); - images--; - } - shell.querySelector(".reader-pdf-text")?.replaceChildren(); - shell._bookmarkTextItems = []; - shell.dataset.textReady = "0"; - shell.dataset.renderState = "idle"; - const pageState = shell.querySelector(".reader-page-state"); - if (pageState) setPdfPageLoadingState(pageState, shell.dataset.page); - } - domSearch.pruneMarks(); -} -function clearPdfManifestPrefetches() { - stopPdfManifestPrefetch(); - pdfManifestPrefetchNext = 0; - pdfManifestPrefetchOrigin = 0; - pdfManifestPrefetchCount = 0; - pdfManifestPrefetchWrapped = false; - pdfManifestShells.length = 0; - for (const { image } of pdfManifestPrefetches.values()) { - image.onload = image.onerror = null; - image.removeAttribute("src"); - } - pdfManifestPrefetches.clear(); -} -trackReaderResource(clearPdfManifestPrefetches); -trackReaderResource(() => { - for (const waiter of pdfRenderWaiters.splice(0)) waiter.reject(readerAbortError()); - for (const waiter of pdfTextWaiters.splice(0)) waiter.reject(readerAbortError()); -}); + +readerBindings.ownPdfPrefetchAndQueues(); let epubRendition = null; let epubBook = null; let foliateContinuous = false, @@ -677,166 +880,38 @@ let pageNavigationLockUntil = 0; let pendingBookmarkSnapshot = null; let mediaElement = null; let swfPlayer = null; -function nextReaderGeneration(name) { - const generation = readerRuntime.nextGeneration(name); - if (name === "search") drainPdfTextWaiters(); - return generation; -} -function isReaderGenerationCurrent(name, value) { - return !readerAbortController.signal.aborted && readerRuntime.isCurrent(name, value); -} -for (const generationName of ["navigation", "search", "bookmarks", "history", "pdf"]) - nextReaderGeneration(generationName); -function beginReaderNavigation() { - if (epubSeekFrame) cancelAnimationFrame(epubSeekFrame); - epubSeekFrame = 0; - foliateScrollAnchors.invalidate(); - return nextReaderGeneration("navigation"); -} -function reportNavigationError(error, generation) { - if (isReaderGenerationCurrent("navigation", generation) && error?.name !== "AbortError") - console.warn("Reader navigation failed", error); -} + +readerBindings.initializeGenerations(); + const viewport = document.querySelector("#viewport"); const zoomInput = document.querySelector("#zoom"); const pageInput = document.querySelector("#page-number"); const readerPath = document.querySelector("#reader-path"); const bookmarkRibbon = document.querySelector("#bookmark-ribbon"); const bookmarkPopover = document.querySelector("#bookmark-popover"); -for (const [selector, label] of [ - ["#back", "返回"], - ["#page-prev", "上一页"], - ["#page-next", "下一页"], - ["#page-number", "页码"], - ["#zoom-out", "缩小"], - ["#zoom-in", "放大"], - ["#zoom", "缩放百分比"], - ["#bookmark-ribbon", "添加书签"], - ["#history", "阅读选项"] -]) - document.querySelector(selector)?.setAttribute("aria-label", label); -function foliateSectionRoot(article) { - return article?.shadowRoot || article; -} -function foliateSectionQuery(article, selector) { - return foliateSectionRoot(article)?.querySelector(selector) || null; -} -function foliateSectionCandidates() { - return [ - ...content.querySelectorAll( - ".foliate-continuous > article[data-section]:not(.foliate-section-placeholder), .reader-epub-chapter" - ) - ].flatMap((article) => [ - article, - ...foliateSectionRoot(article).querySelectorAll( - ":is(h1,h2,h3,h4,h5,h6,p,li,blockquote,pre,img,table)" - ) - ]); -} +readerBindings.labelToolbarControls(); + const foliateScrollAnchors = readerRuntime.track( VoiceOfMLReaderScroll.createScrollAnchorManager({ viewport, candidates: foliateSectionCandidates }) ); -function applyReaderPathMetadata(data) { - const folder = Array.isArray(data?.folder) ? data.folder.join("/") : String(data?.folder || ""); - const value = String( - data?.path || [data?.repo, folder].filter(Boolean).join("/") || readerPathLabel - ).replace(/^\/+|\/+$/g, ""); - folderNavigationTarget = null; - try { - const target = new URL(folderReturnUrl, location.origin); - if ( - folderReturnUrl && - target.origin === location.origin && - target.pathname.split("/").filter(Boolean).length === 1 - ) - folderNavigationTarget = target; - } catch (_) {} - if (!folderNavigationTarget && data?.repo) { - folderNavigationTarget = new URL("/" + encodeURIComponent(data.repo), location.origin); - if (folder) folderNavigationTarget.searchParams.set("folder_self", folder); - } - readerPath.textContent = value; - readerPath.hidden = !value; - readerPath.disabled = !folderNavigationTarget; - readerPath.setAttribute("aria-label", `筛选文件夹:${value}`); -} -applyReaderMetadata(resolvedReaderData); -contentSanitizerObserver = new MutationObserver(() => { - const seen = new Set(); - for (const article of document.querySelectorAll(".foliate-continuous > article[data-section]")) { - article.querySelectorAll("style,link[rel=stylesheet]").forEach((node) => node.remove()); - const index = article.dataset.section; - if (seen.has(index)) article.remove(); - else seen.add(index); - } -}); -contentSanitizerObserver.observe(content, { childList: true, subtree: true }); -bookmarkPopover.className = "bookmark-popover"; -bookmarkPopover.setAttribute("role", "dialog"); -bookmarkPopover.setAttribute("aria-modal", "true"); -bookmarkPopover.setAttribute("aria-labelledby", "bookmark-prompt"); + +readerBindings.applyMetadataAndWatchSanitizedContent(); const bookmarkLabelInput = document.createElement("input"), bookmarkExcerptInput = document.createElement("textarea"), bookmarkEditFields = document.createElement("div"); -bookmarkLabelInput.id = "bookmark-label"; -bookmarkLabelInput.maxLength = 120; -bookmarkExcerptInput.id = "bookmark-excerpt-input"; -bookmarkExcerptInput.maxLength = 500; -bookmarkExcerptInput.rows = 3; -bookmarkEditFields.className = "bookmark-edit-fields"; -bookmarkEditFields.innerHTML = ""; -bookmarkEditFields.children[0].appendChild(bookmarkLabelInput); -bookmarkEditFields.children[1].appendChild(bookmarkExcerptInput); -bookmarkPopover.insertBefore(bookmarkEditFields, bookmarkPopover.querySelector("div")); +readerBindings.initializeBookmarkEditor(); const bookmarksAllButton = document.createElement("button"); -if (!document.querySelector("#bookmarks-panel .panel-search-toggle")) { - const button = document.createElement("button"); - button.className = "panel-search-toggle"; - button.type = "button"; - document.querySelector("#bookmarks-panel .panel-view-header").appendChild(button); -} -bookmarksAllButton.id = "bookmarks-all"; -bookmarksAllButton.className = "text-action"; -bookmarksAllButton.type = "button"; -bookmarksAllButton.textContent = "全部书签"; -bookmarksAllButton.setAttribute("aria-pressed", "false"); +readerBindings.initializeBookmarkSearchControls(); const bookmarksHeader = document.querySelector("#bookmarks-panel .panel-view-header"), bookmarksSearchButton = bookmarksHeader.querySelector(".panel-search-toggle"), bookmarksHeaderActions = document.createElement("span"); -bookmarksHeaderActions.append(bookmarksAllButton, bookmarksSearchButton); -bookmarksHeader.appendChild(bookmarksHeaderActions); +readerBindings.mountBookmarkHeaderActions(); const mediaTab = document.createElement("button"), mediaPanel = document.createElement("section"); -mediaTab.id = "media-tab"; -mediaTab.type = "button"; -mediaTab.role = "tab"; -mediaTab.dataset.panel = "media"; -mediaTab.textContent = "播放"; -mediaTab.hidden = !capability.features.media; -mediaPanel.id = "media-panel"; -mediaPanel.className = "reader-panel-view"; -mediaPanel.dataset.panelView = "media"; -mediaPanel.hidden = true; -mediaPanel.innerHTML = - '
播放状态尚未播放
'; -document.querySelector(".reader-panel-tabs").prepend(mediaTab); -document - .querySelector("#history-panel") - .insertBefore(mediaPanel, document.querySelector("#toc-panel")); -mediaPanel - .querySelector(".media-panel-bookmark") - .addEventListener("click", (event) => openBookmarkPopover(event.currentTarget)); -document.querySelector(".page-controls").hidden = !capability.features.pagination; -document.querySelector(".zoom-controls").hidden = !capability.features.zoom; - -status.hidden = true; -mediaTab.setAttribute("aria-controls", "media-panel"); -mediaTab.setAttribute("aria-selected", "false"); -mediaPanel.setAttribute("role", "tabpanel"); -mediaPanel.setAttribute("aria-labelledby", "media-tab"); +readerBindings.mountMediaPanel(); // Theme, toolbar, and document navigation. let readerTheme = localStorage.getItem("theme") === "light" ? "light" : "dark"; const THEME_SUN_ICON = @@ -846,5712 +921,42 @@ const THEME_MOON_ICON = const readerThemeToggle = document.querySelector("#theme-toggle"); const pageInvertToggle = document.querySelector("#page-invert-toggle"); let pageInverted = false; -try { pageInverted = localStorage.getItem("reader-page-inversion") === "true"; } catch (_) {} -function applyPageInversion(persist = false) { - document.documentElement.dataset.pageInverted = String(pageInverted); - pageInvertToggle.textContent = pageInverted ? "关闭页面反色" : "页面反色"; - pageInvertToggle.title = pageInvertToggle.textContent; - pageInvertToggle.setAttribute("aria-label", pageInvertToggle.textContent); - pageInvertToggle.setAttribute("aria-pressed", String(pageInverted)); - if (persist) { - try { localStorage.setItem("reader-page-inversion", String(pageInverted)); } catch (_) {} - } -} -pageInvertToggle.addEventListener("click", () => { - pageInverted = !pageInverted; - applyPageInversion(true); -}); -applyPageInversion(); +readerBindings.restoreAndBindPageInversion(); let themeAnimationTimer = 0; -readerThemeToggle.className = "theme-toggle"; -function applyReaderTheme(theme, persist = true, animate = true) { - const anchor = foliateScrollAnchors.capture(); - if (animate) { - clearTimeout(themeAnimationTimer); - document.documentElement.classList.add("theme-transition"); - void document.documentElement.offsetWidth; - } - readerTheme = theme === "light" ? "light" : "dark"; - document.documentElement.dataset.theme = readerTheme; - readerThemeToggle.innerHTML = readerTheme === "dark" ? THEME_MOON_ICON : THEME_SUN_ICON; - if (animate) { - readerThemeToggle.classList.remove("is-changing"); - void readerThemeToggle.offsetWidth; - readerThemeToggle.classList.add("is-changing"); - themeAnimationTimer = setTimeout(() => { - document.documentElement.classList.remove("theme-transition"); - readerThemeToggle.classList.remove("is-changing"); - }, 280); - } - readerThemeToggle.title = readerTheme === "dark" ? "切换到白天模式" : "切换到夜间模式"; - readerThemeToggle.setAttribute("aria-label", readerThemeToggle.title); - readerThemeToggle.setAttribute("aria-pressed", String(readerTheme === "light")); - const docxBody = content.querySelector(".docx-body"); - if (docxBody) docxBody.classList.toggle("reader-document-dark", readerTheme === "dark"); - for (const article of content.querySelectorAll(".foliate-continuous > article")) - article.classList.toggle("reader-document-dark", readerTheme === "dark"); - if (persist) localStorage.setItem("theme", readerTheme); - foliateScrollAnchors.restore(anchor); -} -applyReaderTheme(readerTheme, false, false); -function clearReturnNavigation() { - try { - if (returnHistoryKey) sessionStorage.removeItem(returnHistoryKey); - const saved = JSON.parse(sessionStorage.getItem("reader-navigation-current") || "null"); - if (saved && (saved.readerUrl === location.href || saved.shareUrl === location.href)) - sessionStorage.removeItem("reader-navigation-current"); - } catch (_) {} -} -function navigateReaderFolder() { - if (!folderNavigationTarget) return; - if (window.parent !== window) - window.parent.postMessage( - { type: "voice-reader-navigate", url: folderNavigationTarget.href }, - location.origin - ); - else { - clearReturnNavigation(); - location.assign(folderNavigationTarget.href); - } -} -readerPath.addEventListener("click", navigateReaderFolder); -document.querySelector("#back").addEventListener("click", async () => { - clearTimeout(saveTimer); - await Promise.race([saveProgress(), new Promise((resolve) => setTimeout(resolve, 300))]); - if (window.parent !== window) { - window.parent.postMessage({ type: "voice-reader-close" }, location.origin); - return; - } - clearReturnNavigation(); - try { - const target = new URL(returnUrl, location.origin); - if (target.origin === location.origin) { - if (returnNeedsReload) location.replace(target.href); - else if (canReturnWithHistory && history.length > 2) { - history.go(-2); - } else location.assign(target.href); - return; - } - } catch (_) {} - location.assign("/"); -}); +readerBindings.restoreReaderThemeAndBindReturn(); let zoomChangedByUser = false; -function setZoom(percent, persist = true) { - const anchor = foliateScrollAnchors.capture(); - const pdfAnchor = pdfHybridUrl ? pageAtMarker() : null; - const pdfBounds = pdfAnchor?.getBoundingClientRect(); - const pdfOffset = pdfBounds ? (viewport.getBoundingClientRect().top - pdfBounds.top) / Math.max(1, pdfBounds.height) : 0; - const normalized = VoiceOfMLReader.clampNumber(percent, 25, 400, 100); - const horizontalCenter = viewport.scrollWidth - ? (viewport.scrollLeft + viewport.clientWidth / 2) / viewport.scrollWidth - : 0; - updateDocumentState({ zoom: normalized / 100 }); - content.style.setProperty("--reader-zoom", String(documentState.zoom)); - zoomInput.value = String(normalized); - pdfShellWindow?.resize(); - if (pdfAnchor?.isConnected) { - const bounds = pdfAnchor.getBoundingClientRect(); - viewport.scrollTop += bounds.top - viewport.getBoundingClientRect().top + pdfOffset * bounds.height; - } - if (htmlFrame && htmlFrame.contentDocument) - htmlFrame.contentDocument.documentElement.style.zoom = String(documentState.zoom); - if (pdfDocument) rerenderVisiblePdfPages(); - if (capability.mode === "foliate") - content.style.setProperty("--reader-zoom", String(documentState.zoom)); - viewport.scrollLeft = horizontalCenter * viewport.scrollWidth - viewport.clientWidth / 2; - if (persist) { - zoomChangedByUser = true; - localStorage.setItem("reader-zoom", String(normalized)); - } - foliateScrollAnchors.restore(anchor); - if (persist) scheduleSave(); -} + const handlePageNavigationFailure = (error, generation) => { if (isReaderGenerationCurrent("navigation", generation) && error?.name !== "AbortError") fail("原文件加载失败,请检查网络后重试,或下载原文件。", "READER_NAVIGATION"); }; -setZoom(documentState.zoom * 100, false); -for (const [id, delta] of [ - ["#zoom-out", -10], - ["#zoom-in", 10] -]) { - document.querySelector(id).addEventListener("click", () => { - setZoom(Number(zoomInput.value) + delta); - }); -} -zoomInput.addEventListener("change", () => setZoom(zoomInput.value)); -zoomInput.addEventListener("keydown", (event) => { - if (event.key === "Enter") { - setZoom(zoomInput.value); - zoomInput.blur(); - } -}); -pageInput.addEventListener("change", () => goToPage(pageInput.value)); -pageInput.addEventListener("keydown", (event) => { - if (event.key === "Enter") { - goToPage(pageInput.value); - pageInput.blur(); - } -}); -function turnViewport(direction) { - beginReaderNavigation(); - const target = htmlFrame?.contentWindow || viewport; - target.scrollBy({ - top: - Math.max(160, (htmlFrame?.contentWindow?.innerHeight || viewport.clientHeight) * 0.86) * - direction, - behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth" - }); -} -function handleReaderKeydown(event) { - if ( - event.defaultPrevented || - event.isComposing || - event.altKey || - event.ctrlKey || - event.metaKey || - event.target.closest?.( - "input,textarea,select,button,a,audio,video,[contenteditable]:not([contenteditable=false])" - ) - ) - return; - if (["PageDown", " ", "ArrowDown", "PageUp", "ArrowUp"].includes(event.key)) { - event.preventDefault(); - turnViewport( - ["PageUp", "ArrowUp"].includes(event.key) || (event.key === " " && event.shiftKey) ? -1 : 1 - ); - } -} -document.addEventListener("keydown", handleReaderKeydown); -document - .querySelector("#page-prev") - .addEventListener("click", () => - documentState.pageCount ? goToPage(documentState.page - 1) : turnViewport(-1) - ); -document - .querySelector("#page-next") - .addEventListener("click", () => - documentState.pageCount ? goToPage(documentState.page + 1) : turnViewport(1) - ); +readerBindings.bindZoomAndPageControls(); -function readerPageScrollTop(shell) { - return viewport.scrollTop + shell.getBoundingClientRect().top - - viewport.getBoundingClientRect().top - viewport.clientTop; -} -async function goToPage(value, generation = beginReaderNavigation()) { - try { - if (!documentState.pageCount || !isReaderGenerationCurrent("navigation", generation)) - return false; - const page = VoiceOfMLReader.clampNumber(value, 1, documentState.pageCount, 1); - pdfShellWindow?.ensure(page); - let shell = content.querySelector( - `.reader-page[data-page="${page}"], .reader-docx-page[data-page="${page}"]` - ); - if (!shell && ["pdf-pages", "image-pages"].includes(capability.mode)) { - await pdfShellsReady; - shell = content.querySelector(`.reader-page[data-page="${page}"]`); - } - if (!isReaderGenerationCurrent("navigation", generation)) return false; - if (!shell) return false; - const imagePage = ["pdf-pages", "image-pages"].includes(capability.mode); - if (imagePage && shell.dataset.renderState !== "rendered") - prioritizePdfManifestPrefetch(pdfPageManifest?.pageUrl(page)); - const pendingPdf = - !imagePage && shell.classList.contains("reader-page") - ? renderPdfInBackground(shell, false, true) - : null; - if (!isReaderGenerationCurrent("navigation", generation)) return false; - viewport.scrollTop = readerPageScrollTop(shell); - if (imagePage && shell.dataset.renderState === "rendered") renderPdfOcrText(shell); - pageNavigationLockUntil = performance.now() + 500; - if (pendingPdf) cancelSpeculativePdfRenders(shell); - const pendingImage = imagePage ? renderPdfInBackground(shell, false, true) : null; - if (imagePage && shell.dataset.renderState !== "rendered") cancelSpeculativePdfRenders(shell); - updateDocumentState({ page }); - pageInput.value = String(page); - updateTocCurrentMark(); - updateProgressTools(); - pendingPdf?.catch(() => {}); - pendingImage?.catch(() => {}); - if (!isReaderGenerationCurrent("navigation", generation)) return false; - scheduleSave(); - return true; - } catch (error) { - handlePageNavigationFailure(error, generation); - return false; - } -} -function scheduleSave() { - if (readerLifecycle.disposed || readerAbortController.signal.aborted) return; - const now = performance.now(); - if (!saveDeadline) saveDeadline = now + 2000; - readerRuntime.cancel(saveTimer); - saveTimer = readerRuntime.schedule( - () => { - saveTimer = 0; - saveDeadline = 0; - saveProgress(); - }, - Math.max(0, Math.min(500, saveDeadline - now)) - ); -} -async function saveProgress(event) { - if ( - readerLifecycle.disposed || - !documentState.restorationReady || - !validSource(sourceUrl) || - historySuppressed - ) - return; - if (event?.type !== "pagehide" && document.visibilityState !== "hidden") - syncCurrentPageFromMarker(); - const shell = documentState.pageCount - ? content.querySelector( - `.reader-page[data-page="${documentState.page}"], .reader-docx-page[data-page="${documentState.page}"]` - ) - : null; - const pageOffset = shell ? Math.max(0, viewport.scrollTop - readerPageScrollTop(shell)) : 0; - const htmlScrollTop = htmlFrame && htmlFrame.contentWindow ? htmlFrame.contentWindow.scrollY : 0; - const readerUrl = currentReaderUrl(); - const foliatePosition = captureFoliateBookmarkPosition() || captureChapterPosition(); - const progress = { - url: sourceUrl, - title: documentState.title, - extension, - readerUrl: readerUrl.href, - page: documentState.page, - pageCount: documentState.pageCount, - pageOffset, - mediaTime: - mediaElement && Number.isFinite(mediaElement.currentTime) ? mediaElement.currentTime : 0, - scrollTop: viewport.scrollTop, - htmlScrollTop, - zoom: Math.round(documentState.zoom * 100), - ...(foliatePosition || {}) - }; - const signature = JSON.stringify(progress); - if (signature === lastSavedProgress) return progressSaveChain; - lastSavedProgress = signature; - progressSaveChain = progressSaveChain - .catch(() => {}) - .then(() => { - if (!historySuppressed) - return VoiceOfMLReaderStore.put({ ...progress, lastReadAt: Date.now() }); - }) - .catch((error) => { - if (lastSavedProgress === signature) lastSavedProgress = ""; - console.warn("Reader progress was not saved", error); - }); - return progressSaveChain; -} -function emptyPanel(list, message) { - list.innerHTML = `
${message}
`; -} -function clearSearchHighlights(view) { - for (const mark of view.querySelectorAll("mark.search-match")) mark.replaceWith(mark.textContent); - view.normalize(); -} -function highlightPanelItem(item, query) { - const nodes = []; - const walker = document.createTreeWalker(item, NodeFilter.SHOW_TEXT); - while (walker.nextNode()) - if (!walker.currentNode.parentElement.closest(".panel-item-remove")) - nodes.push(walker.currentNode); - const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "giu"); - for (const node of nodes) { - const text = node.data, - matches = [...text.matchAll(pattern)]; - if (!matches.length) continue; - let start = 0; - const fragment = document.createDocumentFragment(); - for (const match of matches) { - fragment.append(text.slice(start, match.index)); - const mark = document.createElement("mark"); - mark.className = "search-match"; - mark.textContent = match[0]; - fragment.append(mark); - start = match.index + match[0].length; - } - fragment.append(text.slice(start)); - node.replaceWith(fragment); - } -} -function filterPanel(view) { - clearSearchHighlights(view); - const query = (view.querySelector(".panel-search").value || "").trim().toLowerCase(); - for (const item of view.querySelectorAll(".panel-item")) { - const searchableText = [...item.children] - .filter((child) => !child.classList.contains("panel-item-remove")) - .map((child) => child.textContent) - .join(" ") - .toLowerCase(); - item.hidden = !!query && !searchableText.includes(query); - if (query && !item.hidden) highlightPanelItem(item, query); - } -} let panelAnimationTimer = 0; let readerPanelInteractionSerial = 0; let readerUiHistoryState = "base"; -if (window.parent === window) { - history.replaceState({ ...history.state, voiceReaderBase: true }, "", location.href); - history.pushState({ voiceReaderGuard: true }, "", location.href); - window.addEventListener("popstate", (event) => { - if (!event.state?.voiceReaderBase || readerLifecycle.disposed) return; - if (document.querySelector("#history-panel").classList.contains("is-open") && !fullSearchView.hidden) - closeFullSearchView(); - else if (document.querySelector("#history-panel").classList.contains("is-open")) - setReaderPanelOpen(false, true); - else { - history.back(); - return; - } - history.pushState({ voiceReaderGuard: true }, "", location.href); - }); -} -function notifyReaderUiState(state) { - readerUiHistoryState = state; - if (window.parent !== window) - window.parent.postMessage({ type: "voice-reader-ui-state", state }, location.origin); -} -function setReaderPanelOpen(open, restoreFocus = false) { - readerRuntime.update("panel", { open }); - const panel = document.querySelector("#history-panel"); - clearTimeout(panelAnimationTimer); - document.querySelector("#history").setAttribute("aria-expanded", String(open)); - if (open) { - readerPanelInteractionSerial++; - panel.hidden = false; - void panel.offsetWidth; - panel.classList.add("is-open"); - selectPanel(panelState.selected === "full-search" ? "full-search" : mediaElement ? "media" : "toc"); - updateTocCurrentMark(); - notifyReaderUiState(panelState.selected === "full-search" ? "full-search" : "panel"); - return; - } - panel.classList.remove("is-open"); - panelAnimationTimer = setTimeout(() => { - if (!panel.classList.contains("is-open")) panel.hidden = true; - }, 250); - notifyReaderUiState("base"); - if (restoreFocus) document.querySelector("#history").focus({ preventScroll: true }); -} -function setPanelSearchOpen(button, open) { - const input = button.closest(".reader-panel-view").querySelector(".panel-search"); - button.setAttribute("aria-expanded", String(open)); - if (open) { - input.hidden = false; - void input.offsetWidth; - input.classList.add("is-open"); - input.focus(); - return; - } - input.classList.remove("is-open"); - setTimeout(() => { - if (!input.classList.contains("is-open")) input.hidden = true; - }, 190); - button.focus(); -} -function selectPanel(name) { - if (name === "media" && mediaTab.hidden) name = "bookmarks"; - updatePanelState({ selected: name }); - if (progressTools) progressTools.hidden = !["toc", "media"].includes(name); - for (const button of document.querySelectorAll(".reader-panel-tabs button")) { - const selected = button.dataset.panel === name; - button.setAttribute("aria-selected", String(selected)); - button.tabIndex = selected ? 0 : -1; - } - for (const view of document.querySelectorAll(".reader-panel-view")) - view.hidden = view.dataset.panelView !== name; - if (name === "full-search") notifyReaderUiState("full-search"); - else if (panelState.open) notifyReaderUiState("panel"); - if (name === "bookmarks") renderBookmarks(); - if (name === "history") renderHistory(); - if (name === "media" && mediaElement) - mediaPanel.querySelector(".media-panel-time").textContent = - `${formatMediaTime(mediaElement.currentTime)} / ${formatMediaTime(mediaElement.duration)}`; - updateProgressTools(); -} -function headingTocEntries(root) { - return [...root.querySelectorAll("h1,h2,h3,h4,h5,h6")] - .filter((heading) => heading.textContent.trim()) - .map((target) => ({ - label: target.textContent.trim(), - depth: Number(target.tagName.slice(1)) - 1, - target, - activate: () => target.scrollIntoView({ block: "start" }) - })); -} +readerBindings.bindReaderUiHistory(); + let headingLocationCache = null; -trackReaderResource(() => { headingLocationCache?.dispose(); headingLocationCache = null; }); -function createHeadingLocationCache(root, entries) { - let positions = null, targets = [], width = -1, extent = -1, zoom = -1, wasAnimating = false, live = false; - const invalidate = () => { positions = null; }; - const mutations = new MutationObserver(invalidate), resize = new ResizeObserver(invalidate); - mutations.observe(root, {subtree:true, childList:true, characterData:true, - attributes:true, attributeFilter:["style", "class", "hidden", "width", "height"]}); - resize.observe(root); - root.addEventListener("load", invalidate, true); - root.ownerDocument.fonts?.addEventListener("loadingdone", invalidate); - return { root, entries, - at(marker, scroll) { - if (mutations.takeRecords().length) invalidate(); - const nextWidth = root.clientWidth, nextExtent = root.scrollHeight, nextZoom = documentState.zoom; - const animating = root.getAnimations?.({subtree:true}).some(animation => animation.playState === "running" || animation.pending) || false; - if (live || animating || wasAnimating || nextWidth !== width || nextExtent !== extent || nextZoom !== zoom || - targets.some(row => row.target !== row.entry.target || row.connected !== !!row.target?.isConnected)) invalidate(); - wasAnimating = animating; - if (!positions) { - targets = entries.map((entry, index) => ({entry, index, target:entry.target, connected:!!entry.target?.isConnected})); - const fixed = new WeakMap(); - const scrollDependent = target => { - const ancestors = []; - let value = false; - for (let node = target; node; node = node.parentElement) { - if (fixed.has(node)) { value = fixed.get(node); break; } - ancestors.push(node); - const position = root.ownerDocument.defaultView?.getComputedStyle(node).position; - if (position === "fixed" || position === "sticky") { value = true; break; } - if (node === root) break; - } - for (const node of ancestors) fixed.set(node, value); - return value; - }; - // Source CSS may pin headings while scrolling; keep their live geometry. - live = targets.some(row => row.connected && scrollDependent(row.target)); - positions = targets.filter(row => row.connected).map(row => ({index:row.index, - top:row.target.getBoundingClientRect().top + scroll - marker})).sort((a,b) => a.top-b.top || a.index-b.index); - width = root.clientWidth; extent = root.scrollHeight; zoom = nextZoom; - } - let low = 0, high = positions.length; - while (low < high) { - const middle = (low + high) >> 1; - if (positions[middle].top <= scroll) low = middle + 1; - else high = middle; - } - return positions[low ? low - 1 : 0]?.index ?? -1; - }, - dispose() { - mutations.disconnect(); resize.disconnect(); - root.removeEventListener("load", invalidate, true); - root.ownerDocument.fonts?.removeEventListener("loadingdone", invalidate); - positions = null; targets = []; - } - }; -} -function syncHeadingLocation() { - const marker = htmlFrame ? 8 : viewport.getBoundingClientRect().top + 8; - const root = htmlFrame?.contentDocument?.body || content; - if (!["foliate", "epub-chapters"].includes(capability.mode)) { - if (headingLocationCache?.root !== root || headingLocationCache?.entries !== navigationState.tocEntries) { - headingLocationCache?.dispose(); - headingLocationCache = createHeadingLocationCache(root, navigationState.tocEntries); - } - const current = headingLocationCache.at(marker, htmlFrame ? htmlFrame.contentWindow.scrollY : viewport.scrollTop); - if (current >= 0 && current !== navigationState.currentChapterIndex) { - updateNavigationState({currentChapterIndex:current}); - updateTocCurrentMark(); - } - return; - } - let current = -1, - currentPosition = -Infinity, - first = -1, - firstPosition = Infinity; - for (const [index, entry] of navigationState.tocEntries.entries()) { - if (!entry.target?.isConnected) continue; - const position = entry.target.getBoundingClientRect().top; - if (position <= marker && (position > currentPosition || (position === currentPosition && index > current))) { - current = index; - currentPosition = position; - } - if (position > marker && position < firstPosition) { - first = index; - firstPosition = position; - } - } - if (current < 0) current = first; - if (current >= 0 && current !== navigationState.currentChapterIndex) { - updateNavigationState({ currentChapterIndex: current }); - updateTocCurrentMark(); - } -} -function handleReaderPositionChange() { - if (readerAbortController.signal.aborted) return; - if (viewport.scrollTop !== pdfLastScrollTop) pdfScrollDirection = viewport.scrollTop > pdfLastScrollTop ? 1 : -1; - pdfLastScrollTop = viewport.scrollTop; - pdfShellWindow?.schedule(); - scheduleMarkerSync(); - scheduleFoliateScrollSync(); - foliateScrollAnchors.remember(); -} -async function navigateTocEntry(index) { - if (index < 0 || index >= navigationState.tocEntries.length) return; - if (navigationState.tocEntries[index].group) return; - const generation = beginReaderNavigation(), - entry = navigationState.tocEntries[index]; - try { - const activated = foliateContinuous - ? await activateFoliateTocEntry(entry, generation) - : await entry.activate(generation); - if (activated === false || !isReaderGenerationCurrent("navigation", generation)) return; - updateNavigationState({ currentChapterIndex: index }); - updateTocCurrentMark(); - updateProgressTools(); - setReaderPanelOpen(false, true); - } catch (error) { - reportNavigationError(error, generation); - } -} -function createTocRow(entry, index) { - const row = document.createElement("div"); - row.className = "panel-item toc-item"; - row.dataset.tocIndex = String(index); - row.style.setProperty("--toc-depth", String(entry.depth || 0)); - const link = document.createElement("div"); - link.className = "panel-item-main"; - link.textContent = entry.label; - link.title = entry.label; - if (entry.group) { - row.classList.add("toc-group"); - link.setAttribute("role", "heading"); - link.setAttribute("aria-level", String(Math.min(6, (entry.depth || 0) + 1))); - row.appendChild(link); - return row; - } - link.tabIndex = 0; - link.setAttribute("role", "link"); - const activate = (event) => { - const selection = getSelection(); - if (event?.type === "click" && selection?.toString() && selection.containsNode(link, true)) return; - navigateTocEntry(index); - }; - link.addEventListener("click", activate); - link.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - activate(); - } - }); - row.appendChild(link); - return row; -} -function renderVirtualToc() { - const state = tocVirtualState; - if (!state) return; - const height = Math.max(320, state.panelView.clientHeight), - start = Math.max(0, Math.floor(state.panelView.scrollTop / TOC_VIRTUAL_ROW_HEIGHT) - 12), - end = Math.min(state.entries.length, start + Math.ceil(height / TOC_VIRTUAL_ROW_HEIGHT) + 24), - fragment = document.createDocumentFragment(); - state.list.replaceChildren(); - for (let index = start; index < end; index++) { - const row = createTocRow(state.entries[index], index); - row.classList.add("toc-virtual-row"); - row.style.top = `${index * TOC_VIRTUAL_ROW_HEIGHT}px`; - fragment.appendChild(row); - } - state.list.appendChild(fragment); - updateTocCurrentMark(); -} -function setToc(entries) { - const normalizedEntries = (entries || []).map((entry) => ({ - ...entry, - href: String(entry.href || ""), - label: cleanTocLabel(entry.label) - })); - updateNavigationState({ - tocEntries: normalizedEntries, - currentChapterIndex: normalizedEntries.findIndex((entry) => !entry.group) - }); - const tocTab = document.querySelector("#toc-tab"); - tocTab.hidden = false; - tocTab.textContent = normalizedEntries.length ? "目录" : "阅读"; - const previousList = document.querySelector("#toc-list"), - list = previousList.cloneNode(false); - tocVirtualState?.destroy(); - tocVirtualState = null; - previousList.replaceWith(list); - const virtualizationThreshold = chapterManifestUrl ? 2000 : TOC_VIRTUALIZATION_THRESHOLD; - if (normalizedEntries.length > virtualizationThreshold) { - const panelView = list.parentElement; - list.classList.add("toc-list-virtualized"); - list.style.height = `${normalizedEntries.length * TOC_VIRTUAL_ROW_HEIGHT}px`; - let frame = 0; - const onScroll = () => { - if (frame) return; - frame = requestAnimationFrame(() => { - frame = 0; - renderVirtualToc(); - }); - }; - panelView.addEventListener("scroll", onScroll, { passive: true }); - tocVirtualState = { - entries: normalizedEntries, - list, - panelView, - render: renderVirtualToc, - destroy: () => { - panelView.removeEventListener("scroll", onScroll); - if (frame) cancelAnimationFrame(frame); - } - }; - renderVirtualToc(); - } else { - for (const [index, entry] of normalizedEntries.entries()) - list.appendChild(createTocRow(entry, index)); - } - updateTocCurrentMark(); -} -function cleanTocLabel(value) { - return ( - String(value || "未命名章节") - .replace(/[\uFFFD\u2610-\u2612\u25a1\u0000-\u001F\u007F]/gu, "") - .replace(/\s{2,}/gu, " ") - .trim() || "未命名章节" - ); -} -function updateTocCurrentMark() { - const rows = [...document.querySelectorAll("#toc-list .toc-item")]; - if (documentState.pageCount > 1 && documentState.page > 0) { - let pageIndex = -1; - for (const [index, entry] of navigationState.tocEntries.entries()) { - const match = entry.label.match(/第\s*(\d+)\s*页/u); - if (match && Number(match[1]) <= documentState.page) pageIndex = index; - } - if (pageIndex >= 0) updateNavigationState({ currentChapterIndex: pageIndex }); - } - for (const row of rows) { - const index = Number(row.dataset.tocIndex); - let mark = row.querySelector(".toc-current-mark"); - if (!mark) { - mark = document.createElement("span"); - mark.className = "toc-current-mark"; - mark.textContent = "✓"; - row.appendChild(mark); - } - mark.hidden = index !== navigationState.currentChapterIndex; - row.classList.toggle("is-current", index === navigationState.currentChapterIndex); - } -} -function syncFoliateScrollLocation() { - if (!foliateContinuous || !navigationState.tocEntries.length) return; - const rows = [...document.querySelectorAll(".foliate-continuous article[data-section]")], - top = viewport.getBoundingClientRect().top + 80; - let sectionIndex = -1, - sectionNode = null; - for (const row of rows) - if (row.getBoundingClientRect().top <= top) { - sectionIndex = Number(row.dataset.section); - sectionNode = row; - } - if (!sectionNode) return; - let best = -1; - let bestPosition = -Infinity; - for (const [index, entry] of navigationState.tocEntries.entries()) { - if (entry.sectionIndex !== sectionIndex) continue; - let position = sectionNode.getBoundingClientRect().top; - if (entry.anchor) { - const root = foliateSectionRoot(sectionNode); - const anchor = entry.anchor({ - getElementById: (id) => root.querySelector(`[id="${CSS.escape(id)}"]`), - querySelector: (selector) => root.querySelector(selector) - }); - if (anchor) position = anchor.getBoundingClientRect().top; - } - if (entry.fragment) { - const anchor = foliateSectionQuery( - sectionNode, - `[id="${CSS.escape(entry.fragment)}"], [name="${CSS.escape(entry.fragment)}"]` - ); - if (anchor) position = anchor.getBoundingClientRect().top; - } - if (position <= top && (position > bestPosition || (position === bestPosition && index > best))) { - best = index; - bestPosition = position; - } - } - if (best < 0) { - let firstPosition = Infinity; - for (const [index, entry] of navigationState.tocEntries.entries()) { - if (entry.sectionIndex !== sectionIndex) continue; - const position = entry.target?.getBoundingClientRect?.().top; - if (Number.isFinite(position) && position < firstPosition) { - best = index; - firstPosition = position; - } - } - } - if (best >= 0 && best !== navigationState.currentChapterIndex) { - updateNavigationState({ currentChapterIndex: best }); - updateTocCurrentMark(); - updateProgressTools(); - } -} -function refreshFoliateWindow() { - foliateWindowFrame = 0; - foliateSectionLoader?.refresh?.(); -} -function scheduleFoliateWindowRefresh() { - if (!foliateWindowFrame) foliateWindowFrame = requestAnimationFrame(refreshFoliateWindow); -} -function trimFoliateSections() { - if (!foliateContinuous || !foliateSectionVirtualizer) return; - const marker = viewport.getBoundingClientRect().top + 80, - rows = [...document.querySelectorAll(".foliate-continuous article[data-section]")], - current = - rows.find((row) => { - const rect = row.getBoundingClientRect(); - return rect.top <= marker && rect.bottom > marker; - }) || rows.find((row) => row.getBoundingClientRect().top > marker); - if (current) foliateSectionVirtualizer.trim(Number(current.dataset.section)); -} -function scheduleFoliateScrollSync() { - if (foliateScrollFrame) return; - foliateScrollFrame = requestAnimationFrame(() => { - foliateScrollFrame = 0; - syncFoliateScrollLocation(); - foliateScrollAnchors.whenIdle(trimFoliateSections); - }); -} -async function activateFoliateTocEntry(entry, generation = beginReaderNavigation()) { - const book = epubRendition?.book, - href = String(entry.href || ""), - target = await epubRendition?.book?.resolveHref(href); - if (!book || !isReaderGenerationCurrent("navigation", generation)) return false; - const section = target && book.sections[target.index], - sections = book.sections.filter((item) => item.linear !== "no"), - visibleIndex = section ? sections.indexOf(section) : -1; - if (!section || visibleIndex < 0 || !foliateSectionLoader) return false; - let node = await foliateSectionLoader(visibleIndex); - if (!isReaderGenerationCurrent("navigation", generation)) return false; - // Background neighbors are not part of a demanded TOC destination. - await waitForReader(0, true); - if (!isReaderGenerationCurrent("navigation", generation)) return false; - if (!node?.isConnected) node = await foliateSectionLoader(visibleIndex); - if (!node?.isConnected || !isReaderGenerationCurrent("navigation", generation)) return false; - const root = foliateSectionRoot(node), - anchorDocument = { - getElementById: (id) => root.querySelector(`[id="${CSS.escape(id)}"]`), - querySelector: (selector) => root.querySelector(selector) - }; - let anchor = null; - try { - anchor = typeof target.anchor === "function" ? target.anchor(anchorDocument) : null; - } catch (_) {} - if (!anchor) { - const fragment = href.split("#")[1] || "", - id = fragment ? decodeURIComponent(fragment) : ""; - anchor = id ? root.querySelector(`[id="${CSS.escape(id)}"], [name="${CSS.escape(id)}"]`) : null; - } - const destination = anchor || node; - foliateScrollAnchors.invalidate(); - viewport.scrollTop = Math.max( - 0, - viewport.scrollTop + - destination.getBoundingClientRect().top - - viewport.getBoundingClientRect().top - - 8 - ); - syncFoliateScrollLocation(); - updateTocCurrentMark(); - updateProgressTools(); - scheduleSave(); - setReaderPanelOpen(false, true); - foliateScrollAnchors.remember(); - return true; -} -document.addEventListener("click", async (event) => { - const path = event.composedPath(), - link = path.find((node) => node instanceof Element && node.matches?.("a[href]")), - article = path.find( - (node) => - node instanceof Element && node.matches?.(".foliate-continuous article[data-section]") - ); - if (!link || !article || !epubRendition?.book) return; - const raw = link.getAttribute("href") || ""; - if (raw.startsWith("/api/reader-resource?")) return; - if (!raw || epubRendition.book.isExternal?.(raw) || /^(?:https?:|mailto:|tel:)/i.test(raw)) - return; - event.preventDefault(); - const generation = beginReaderNavigation(), - index = Number(article.dataset.section), - section = epubRendition.book.sections.filter((item) => item.linear !== "no")[index]; - let href = raw; - try { - href = section?.resolveHref?.(raw) || raw; - } catch (_) {} - try { - await activateFoliateTocEntry({ href }, generation); - } catch (error) { - reportNavigationError(error, generation); - } -}); -function placeReadingProgress() { - const panel = document.querySelector("#history-panel"), - tocPanel = document.querySelector("#toc-panel"), - tocList = tocPanel.querySelector(".panel-list"), - noToc = loadingStatus.hidden && !navigationState.tocEntries.length && !mediaElement; - progressTools.classList.toggle("reader-no-toc", noToc); - tocPanel.querySelector(".panel-view-header strong").textContent = noToc ? "阅读状态" : "目录"; - if ( - noToc && - (progressTools.parentElement !== tocPanel || progressTools.nextElementSibling !== tocList) - ) - tocPanel.insertBefore(progressTools, tocList); - else if (!noToc && panel.lastElementChild !== progressTools) panel.appendChild(progressTools); -} -function currentReaderUrl() { - const url = new URL(location.href); - for (const key of ["return", "nav", "bookmark", "bookmark_source"]) url.searchParams.delete(key); - return url; -} -async function loadReaderRestoration() { - const currentEntry = (entry) => { - if ( - capability.mode === "epub-chapters" && - /-epub-chapters-v5\//.test(chapterManifestUrl || sourceUrl) && - entry?.chapterManifest !== (chapterManifestUrl || sourceUrl) - ) return null; - return entry; - }; - try { - const bookmarkId = params.get("bookmark"), - bookmarkSource = params.get("bookmark_source"); - if (bookmarkId && bookmarkSource === sourceUrl) { - const entries = await VoiceOfMLReaderStore.listBookmarks(sourceUrl); - const bookmark = entries.find((entry) => entry.id === bookmarkId && entry.url === sourceUrl); - if (bookmark) return currentEntry(bookmark); - } - return currentEntry(await VoiceOfMLReaderStore.get(sourceUrl)); - } catch (_) { - restorationFailed = true; - return null; - } -} -async function getInitialReaderRestoration() { - if (!readerRestorationPromise) return null; - if (!readerRestorationLoaded) { - readerRestorationEntry = await awaitReader(Promise.race([ - readerRestorationPromise, - waitForReader(150).then(() => null) - ])); - readerRestorationLoaded = true; - } - return readerRestorationEntry; -} -function initialReaderPage(total) { - const page = Number(readerRestorationEntry?.page); - return Number.isInteger(page) ? Math.max(1, Math.min(total, page)) : 1; -} -function consumeBookmarkHandoff(entry) { - if (!entry || entry.id !== params.get("bookmark") || entry.url !== params.get("bookmark_source")) - return; - const opening = new URL(location.href); - const shareKey = (url) => { - const key = new URL(url); - key.searchParams.delete("return"); - key.searchParams.delete("nav"); - return key.href; - }; - const cleanMatchingUrl = (raw) => { - if (typeof raw !== "string" || !raw) return null; - try { - const url = new URL(raw, location.origin); - if ( - url.origin !== location.origin || - url.pathname !== opening.pathname || - shareKey(url) !== shareKey(opening) - ) - return null; - url.searchParams.delete("bookmark"); - url.searchParams.delete("bookmark_source"); - return url.href; - } catch (_) { - return null; - } - }; - const cleanHistory = (owner) => { - const url = cleanMatchingUrl(owner.location.href); - if (!url) return; - const state = owner.history.state; - const readerUrl = state?.voiceReaderOverlay && cleanMatchingUrl(state.readerUrl); - owner.history.replaceState(readerUrl ? { ...state, readerUrl } : state, "", url); - }; - // Clean only this navigation, not a bookmark-ID cache. A new deliberate click - // can reuse the same bookmark; overlay reload/Back uses the cleaned records. - try { - if ( - window.parent !== window && - window.parent.location.origin === location.origin && - window.frameElement?.matches("iframe.reader-overlay") && - window.parent.history.state?.voiceReaderOverlay === true && - cleanMatchingUrl(window.parent.history.state.readerUrl) - ) - cleanHistory(window.parent); - } catch (_) {} - try { - const saved = JSON.parse(sessionStorage.getItem("reader-navigation-current") || "null"); - const readerUrl = cleanMatchingUrl(saved?.readerUrl), - shareUrl = cleanMatchingUrl(saved?.shareUrl); - if (readerUrl && shareUrl) - sessionStorage.setItem( - "reader-navigation-current", - JSON.stringify({ ...saved, readerUrl, shareUrl }) - ); - } catch (_) {} - try { - cleanHistory(window); - } catch (_) {} - params.delete("bookmark"); - params.delete("bookmark_source"); -} -async function navigateReader(rawUrl, bookmark = null) { - let target; - try { - target = new URL(rawUrl, location.origin); - if ( - target.origin !== location.origin || - target.pathname !== "/static/reader.html" || - target.username || - target.password - ) - return; - target.searchParams.delete("bookmark"); - target.searchParams.delete("bookmark_source"); - if (bookmark) { - if ( - typeof bookmark.id !== "string" || - !bookmark.id || - typeof bookmark.url !== "string" || - !bookmark.url - ) - return; - target.searchParams.set("bookmark", bookmark.id); - target.searchParams.set("bookmark_source", bookmark.url); - } - } catch (_) { - return; - } - const generation = beginReaderNavigation(); - await saveProgress(); - if (!isReaderGenerationCurrent("navigation", generation)) return; - if (window.parent !== window) - window.parent.postMessage({ type: "voice-reader-open", url: target.href }, location.origin); - else location.assign(target.href); -} -async function renderHistory() { - const list = document.querySelector("#history-list"), - generation = nextReaderGeneration("history"); - try { - const entries = await VoiceOfMLReaderStore.list(); - if (!isReaderGenerationCurrent("history", generation)) return; - list.textContent = ""; - for (const entry of entries) { - const row = document.createElement("div"); - row.className = "panel-item"; - if (entry.mediaTime != null) row.dataset.mediaTime = String(entry.mediaTime); - const link = document.createElement("button"); - link.type = "button"; - link.className = "panel-item-main"; - link.textContent = entry.title || entry.url; - link.addEventListener("click", () => navigateReader(entry.readerUrl)); - const meta = document.createElement("small"); - meta.textContent = `${entry.pageCount ? `第 ${entry.page || 1} / ${entry.pageCount} 页 · ` : ""}${new Date(entry.lastReadAt).toLocaleString()}`; - const remove = document.createElement("button"); - remove.type = "button"; - remove.className = "panel-item-remove"; - remove.textContent = "删除"; - remove.addEventListener("click", async () => { - if (entry.url === sourceUrl) { - historySuppressed = true; - await progressSaveChain; - } - await VoiceOfMLReaderStore.remove(entry.url); - renderHistory(); - }); - row.append(link, remove, meta); - list.appendChild(row); - } - if (!list.childElementCount) emptyPanel(list, "暂无阅读记录"); - filterPanel(document.querySelector("#history-view")); - } catch (_) { - if (isReaderGenerationCurrent("history", generation)) emptyPanel(list, "无法读取本地记录"); - } -} -function readerProgressPercent() { - if (mediaElement) - return mediaElement.duration - ? Math.round((mediaElement.currentTime / mediaElement.duration) * 1000) / 10 - : 0; - if (foliateContinuous) { - const position = captureFoliateBookmarkPosition(), - count = epubBook?.sections?.filter((section) => section.linear !== "no").length || 0, - article = - position && - document.querySelector( - `.foliate-continuous article[data-section="${position.foliateSection}"]` - ), - fraction = article - ? Math.max( - 0, - Math.min( - 1, - position.foliateOffset / Math.max(1, article.getBoundingClientRect().height) - ) - ) - : 0; - return count && position - ? Math.round(((position.foliateSection + fraction) / count) * 1000) / 10 - : 0; - } - if (htmlFrame && htmlFrame.contentDocument) { - const doc = htmlFrame.contentDocument.documentElement, - win = htmlFrame.contentWindow; - return ( - Math.round( - Math.max(0, Math.min(1, win.scrollY / Math.max(1, doc.scrollHeight - win.innerHeight))) * - 1000 - ) / 10 - ); - } - return ( - Math.round( - Math.max( - 0, - Math.min(1, viewport.scrollTop / Math.max(1, viewport.scrollHeight - viewport.clientHeight)) - ) * 1000 - ) / 10 - ); -} -function excerptFromCaret(doc, root, x, y) { - let node, - offset = 0; - const position = doc.caretPositionFromPoint ? doc.caretPositionFromPoint(x, y) : null; - if (position) { - node = position.offsetNode; - offset = position.offset; - } else if (doc.caretRangeFromPoint) { - const range = doc.caretRangeFromPoint(x, y); - if (range) { - node = range.startContainer; - offset = range.startOffset; - } - } - if (!node || !root.contains(node)) return ""; - if (node.nodeType !== Node.TEXT_NODE) { - const first = doc.createTreeWalker(node, NodeFilter.SHOW_TEXT).nextNode(); - if (!first) return ""; - node = first; - offset = 0; - } - const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT); - walker.currentNode = node; - let excerpt = node.data.slice(offset); - while (excerpt.length < 220 && walker.nextNode()) excerpt += ` ${walker.currentNode.data}`; - return excerpt.replace(/\s+/g, " ").trim().slice(0, 160); -} -function bookmarkExcerpt() { - if (["image", "audio", "video", "pdf-pages"].includes(capability.mode)) return ""; - if (capability.mode === "pdf") { - const shell = pageAtMarker(), - items = shell && shell._bookmarkTextItems; - if (items && items.length) { - const rect = shell.getBoundingClientRect(), - targetY = - Math.max(0, bookmarkRibbon.getBoundingClientRect().bottom - rect.top) / - Math.max(1, rect.height); - let nearest = 0, - distance = Infinity; - items.forEach((item, index) => { - const nextDistance = Math.abs(item.y - targetY); - if (nextDistance < distance) { - nearest = index; - distance = nextDistance; - } - }); - return items - .slice(nearest) - .map((item) => item.text) - .join(" ") - .replace(/\s+/g, " ") - .trim() - .slice(0, 160); - } - // A scanned/unloaded page has no excerpt. Never fall back to other pages' UI text. - return ""; - } - const x = Math.round(viewport.getBoundingClientRect().width / 2), - y = Math.round(bookmarkRibbon.getBoundingClientRect().bottom + 8); - const frames = [...document.querySelectorAll("iframe")].filter((frame) => { - const rect = frame.getBoundingClientRect(); - return rect.left <= x && rect.right >= x && rect.top <= y && rect.bottom >= y; - }); - const frame = frames[frames.length - 1]; - try { - if (frame && frame.contentDocument && frame.contentDocument.body) { - const rect = frame.getBoundingClientRect(); - return excerptFromCaret( - frame.contentDocument, - frame.contentDocument.body, - x - rect.left, - y - rect.top - ); - } - } catch (_) {} - const exact = excerptFromCaret(document, content, x, y); - if (exact) return exact; - const sourceText = foliateContinuous - ? [ - ...content.querySelectorAll( - ".foliate-continuous article[data-section]:not(.foliate-section-placeholder)" - ) - ] - .map((article) => foliateSectionRoot(article).textContent) - .join(" ") - : content.textContent, - text = sourceText.replace(/\s+/g, " ").trim(), - start = Math.floor((text.length * readerProgressPercent()) / 100); - return text.slice(start, start + 160).trim(); -} -function captureFoliateBookmarkPosition() { - if (!foliateContinuous) return null; - const marker = viewport.getBoundingClientRect().top + 8, - rows = [...document.querySelectorAll(".foliate-continuous article[data-section]")]; - const article = - rows.find((row) => { - const rect = row.getBoundingClientRect(); - return rect.top <= marker && rect.bottom > marker; - }) || - rows.find((row) => row.getBoundingClientRect().top > marker) || - rows[rows.length - 1]; - return article - ? { - foliateSection: Number(article.dataset.section), - foliateOffset: marker - article.getBoundingClientRect().top, - foliateTocIndex: navigationState.currentChapterIndex - } - : null; -} -async function restoreFoliateBookmarkPosition(entry, generation = beginReaderNavigation()) { - try { - if ( - !foliateSectionLoader || - !Number.isInteger(entry.foliateSection) || - !isReaderGenerationCurrent("navigation", generation) - ) - return false; - let article = await foliateSectionLoader(entry.foliateSection); - if (!isReaderGenerationCurrent("navigation", generation)) return false; - await waitForReader(0, true); - if (!isReaderGenerationCurrent("navigation", generation)) return false; - if (!article?.isConnected) article = await foliateSectionLoader(entry.foliateSection); - if (!article?.isConnected || !isReaderGenerationCurrent("navigation", generation)) return false; - const marker = viewport.getBoundingClientRect().top + 8; - const offset = Number(entry.foliateOffset) || 0; - const start = !offset - ? foliateSectionRoot(article).querySelector("h1,h2,h3,h4,h5,h6,p,li,blockquote,pre") || article - : article; - foliateScrollAnchors.invalidate(); - viewport.scrollTop = Math.max( - 0, - viewport.scrollTop + - start.getBoundingClientRect().top - - marker + - offset - ); - syncFoliateScrollLocation(); - updateTocCurrentMark(); - updateProgressTools(); - scheduleSave(); - setReaderPanelOpen(false, true); - foliateScrollAnchors.remember(); - return true; - } catch (error) { - reportNavigationError(error, generation); - return false; - } -} -async function seekFoliateProgress(percent, generation) { - try { - const sections = epubBook?.sections?.filter((section) => section.linear !== "no") || []; - if ( - !sections.length || - !foliateSectionLoader || - !isReaderGenerationCurrent("navigation", generation) - ) - return; - const position = Math.max(0, Math.min(0.999999, Number(percent) / 100)) * sections.length, - index = Math.min(sections.length - 1, Math.floor(position)); - let article = await foliateSectionLoader(index); - if (!isReaderGenerationCurrent("navigation", generation)) return; - await waitForReader(0, true); - if (!isReaderGenerationCurrent("navigation", generation)) return; - if (!article?.isConnected) article = await foliateSectionLoader(index); - if (!article?.isConnected || !isReaderGenerationCurrent("navigation", generation)) return; - const marker = viewport.getBoundingClientRect().top + 8, - offset = article.getBoundingClientRect().height * (position - index); - foliateScrollAnchors.invalidate(); - viewport.scrollTop = Math.max( - 0, - viewport.scrollTop + article.getBoundingClientRect().top - marker + offset - ); - await waitForReader(0, true); - if (!isReaderGenerationCurrent("navigation", generation)) return; - viewport.scrollTop += article.getBoundingClientRect().top - marker + offset + 4; - syncFoliateScrollLocation(); - updateTocCurrentMark(); - updateProgressTools(); - scheduleSave(); - foliateScrollAnchors.remember(); - } catch (error) { - reportNavigationError(error, generation); - } -} -async function renderBookmarks() { - const list = document.querySelector("#bookmarks-list"), - generation = nextReaderGeneration("bookmarks"); - try { - const entries = panelState.showingAllBookmarks - ? await VoiceOfMLReaderStore.listAllBookmarks() - : await VoiceOfMLReaderStore.listBookmarks(sourceUrl); - if (!isReaderGenerationCurrent("bookmarks", generation)) return; - list.textContent = ""; - for (const entry of entries) { - const row = document.createElement("div"); - row.className = "panel-item"; - row.dataset.bookmarkId = entry.id; - const open = document.createElement("button"); - open.type = "button"; - open.className = "panel-item-main"; - open.textContent = panelState.showingAllBookmarks - ? `${entry.title || "未命名书籍"} · ${entry.label}` - : entry.label; - open.addEventListener("click", () => - entry.url !== sourceUrl - ? navigateReader(entry.readerUrl, entry) - : restoreFormat(capability.mode, entry) - ); - const excerpt = document.createElement("p"); - excerpt.className = "bookmark-excerpt"; - excerpt.textContent = entry.excerpt || ""; - excerpt.hidden = !entry.excerpt; - const meta = document.createElement("small"); - meta.textContent = new Date(entry.createdAt).toLocaleString(); - const remove = document.createElement("button"); - remove.type = "button"; - remove.className = "panel-item-remove"; - remove.textContent = "删除"; - remove.addEventListener("click", async () => { - await VoiceOfMLReaderStore.removeBookmark(entry.id); - row.remove(); - if (!list.querySelector(".panel-item")) emptyPanel(list, "暂无书签"); - }); - const edit = document.createElement("button"); - edit.type = "button"; - edit.className = "panel-item-edit"; - edit.textContent = "编辑"; - edit.addEventListener("click", () => openBookmarkPopover(edit, entry)); - row.append(open, edit, remove, excerpt, meta); - list.appendChild(row); - } - if (!list.childElementCount) emptyPanel(list, "暂无书签"); - filterPanel(document.querySelector("#bookmarks-panel")); - } catch (_) { - if (isReaderGenerationCurrent("bookmarks", generation)) emptyPanel(list, "无法读取书签"); - } -} -function pageAtMarker() { - const y = bookmarkRibbon.getBoundingClientRect().bottom, - pages = docxPageNodes.length ? docxPageNodes : [...content.querySelectorAll(".reader-page, .reader-docx-page")]; - if (!pages.length) return null; - const end = viewport.scrollHeight - viewport.clientHeight; - if (["pdf", "pdf-pages"].includes(capability.mode) && end > 2 && viewport.scrollTop > 0 && - end - viewport.scrollTop <= 2) { - const last = pages[pages.length - 1], rect = last.getBoundingClientRect(), view = viewport.getBoundingClientRect(); - if (rect.bottom > view.top && rect.top < view.bottom) return last; - } - let low = 0, high = pages.length; - while (low < high) { - const middle = (low + high) >> 1; - if (pages[middle].getBoundingClientRect().bottom > y) high = middle; - else low = middle + 1; - } - return pages[Math.min(low, pages.length - 1)]; -} -function syncCurrentPageFromMarker() { - if (performance.now() < pageNavigationLockUntil) return; - const page = pageAtMarker(); - if (!page) return; - const next = Number(page.dataset.page); - if (!next || next === documentState.page) return; - updateDocumentState({ page: next }); - if (document.activeElement !== pageInput) pageInput.value = String(next); - updateTocCurrentMark(); - updateProgressTools(); -} -function scheduleMarkerSync() { - if (markerFrame) return; - markerFrame = requestAnimationFrame(() => { - markerFrame = 0; - if (readerAbortController.signal.aborted) return; - syncCurrentPageFromMarker(); - refreshNearbyPdfPages(); - syncHeadingLocation(); - updateProgressTools(); - scheduleSave(); - }); -} -function formatMediaTime(seconds) { - if (!Number.isFinite(seconds) || seconds < 0) return "00:00"; - const total = Math.floor(seconds), - hours = Math.floor(total / 3600), - minutes = Math.floor((total % 3600) / 60), - secs = total % 60; - return `${hours ? `${String(hours).padStart(2, "0")}:` : ""}${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; -} -function captureBookmarkSnapshot() { - if (mediaElement) { - const time = Number.isFinite(mediaElement.currentTime) ? mediaElement.currentTime : 0; - return { - locator: `media:${Math.round(time * 10) / 10}`, - label: `时间 ${formatMediaTime(time)}`, - excerpt: "", - progress: mediaElement.duration ? Math.round((time / mediaElement.duration) * 1000) / 10 : 0, - mediaTime: time, - page: 0, - pageOffset: 0, - scrollTop: 0, - htmlScrollTop: 0 - }; - } - syncCurrentPageFromMarker(); - const foliatePosition = captureFoliateBookmarkPosition() || captureChapterPosition(); - const shell = documentState.pageCount ? pageAtMarker() : null; - const pageOffset = shell ? Math.max(0, viewport.scrollTop - readerPageScrollTop(shell)) : 0; - const progress = readerProgressPercent(); - const htmlScrollTop = htmlFrame && htmlFrame.contentWindow ? htmlFrame.contentWindow.scrollY : 0; - const scrollTop = viewport.scrollTop; - const pagedDocument = documentState.pageCount && !["text", "markdown"].includes(capability.mode); - const locator = Number.isInteger(foliatePosition?.chapterIndex) - ? `chapter:${foliatePosition.chapterIndex}:${Math.round(foliatePosition.chapterOffset)}` - : foliatePosition - ? `foliate:${foliatePosition.foliateSection}:${Math.round(foliatePosition.foliateOffset)}` - : pagedDocument - ? `page:${documentState.page}:${Math.round(pageOffset)}` - : `progress:${progress}:${Math.round(htmlFrame ? htmlScrollTop : scrollTop)}`; - return { - locator, - label: pagedDocument - ? `第 ${documentState.page} / ${documentState.pageCount} 页` - : `阅读进度 ${progress.toFixed(1)}%`, - excerpt: bookmarkExcerpt(), - progress, - page: pagedDocument ? documentState.page : 0, - pageOffset, - scrollTop, - htmlScrollTop, - ...(foliatePosition || {}) - }; -} -// Bookmark dialog owns focus and temporarily makes only its siblings inert. -function setBookmarkDialogModal(open) { - document.documentElement.classList.toggle("bookmark-dialog-open", open); - if (open) { - for (let node = bookmarkPopover; node && node !== document.body; node = node.parentElement) { - for (const sibling of node.parentElement.children) - if (sibling !== node && !bookmarkInertSiblings.has(sibling)) { - bookmarkInertSiblings.set(sibling, sibling.inert); - sibling.inert = true; - } - } - } else { - for (const [node, inert] of bookmarkInertSiblings) node.inert = inert; - bookmarkInertSiblings.clear(); - } -} -function openBookmarkPopover(invoker = bookmarkRibbon, entry = null) { - if (!bookmarkPopover.hidden) return; - bookmarkInvoker = invoker; - pendingBookmarkSnapshot = entry ? null : captureBookmarkSnapshot(); - updatePanelState({ editingBookmark: entry ? { ...entry } : null }); - const snapshot = entry || pendingBookmarkSnapshot; - document.querySelector("#bookmark-prompt").textContent = entry - ? `编辑书签 · ${entry.label}` - : `在${snapshot.label}添加书签?`; - bookmarkLabelInput.value = snapshot.label || ""; - bookmarkExcerptInput.value = snapshot.excerpt || ""; - document.querySelector("#bookmark-add").textContent = entry ? "保存书签" : "添加书签"; - bookmarkPopover.hidden = false; - invoker.setAttribute("aria-expanded", "true"); - invoker.setAttribute("aria-controls", bookmarkPopover.id); - bookmarkLabelInput.focus(); - setBookmarkDialogModal(true); -} -function closeBookmarkPopover() { - pendingBookmarkSnapshot = null; - updatePanelState({ editingBookmark: null }); - bookmarkPopover.hidden = true; - setBookmarkDialogModal(false); - bookmarkInvoker?.setAttribute("aria-expanded", "false"); - (bookmarkInvoker?.isConnected ? bookmarkInvoker : bookmarkRibbon).focus({ preventScroll: true }); - bookmarkInvoker = null; -} -bookmarkRibbon.addEventListener("click", () => openBookmarkPopover(bookmarkRibbon)); -document.querySelector("#bookmark-cancel").addEventListener("click", closeBookmarkPopover); -document.querySelector("#bookmark-add").addEventListener("click", async (event) => { - const button = event.currentTarget, - editing = panelState.editingBookmark; - const snapshot = pendingBookmarkSnapshot || editing || captureBookmarkSnapshot(); - const label = bookmarkLabelInput.value.trim() || snapshot.label, - excerpt = bookmarkExcerptInput.value.trim(); - button.disabled = true; - try { - await VoiceOfMLReaderStore.putBookmark( - editing - ? { ...editing, label, excerpt } - : { - ...snapshot, - id: `${sourceUrl}\0${snapshot.locator}`, - url: sourceUrl, - title: documentState.title, - extension, - readerUrl: currentReaderUrl().href, - label, - excerpt, - createdAt: Date.now() - } - ); - const invoker = bookmarkInvoker; - closeBookmarkPopover(); - if (!document.querySelector("#bookmarks-panel").hidden) { - await renderBookmarks(); - if (editing && invoker && !invoker.isConnected && document.activeElement === document.body) - ( - document.querySelector( - `#bookmarks-list [data-bookmark-id="${CSS.escape(editing.id)}"] .panel-item-edit` - ) || bookmarksAllButton - ).focus(); - } - } catch (_) { - document.querySelector("#bookmark-prompt").textContent = "书签保存失败,请重试。"; - } finally { - button.disabled = false; - } -}); -bookmarkPopover.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - event.preventDefault(); - closeBookmarkPopover(); - return; - } - if (event.key !== "Tab") return; - const controls = [ - ...bookmarkPopover.querySelectorAll("input,textarea,select,button,a[href],[tabindex]") - ].filter((node) => !node.disabled && node.tabIndex >= 0 && node.getClientRects().length); - const first = controls[0], - last = controls[controls.length - 1]; - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } -}); -for (const button of document.querySelectorAll(".reader-panel-tabs button")) - button.addEventListener("click", () => selectPanel(button.dataset.panel)); -document.querySelector(".reader-panel-tabs").addEventListener("keydown", (event) => { - if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; - const tabs = [...document.querySelectorAll(".reader-panel-tabs button:not([hidden])")], - current = tabs.indexOf(document.activeElement); - if (current < 0) return; - event.preventDefault(); - const next = - event.key === "Home" - ? 0 - : event.key === "End" - ? tabs.length - 1 - : (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length; - selectPanel(tabs[next].dataset.panel); - tabs[next].focus(); -}); -bookmarksAllButton.addEventListener("click", () => { - updatePanelState({ showingAllBookmarks: !panelState.showingAllBookmarks }); - bookmarksAllButton.textContent = panelState.showingAllBookmarks ? "本书书签" : "全部书签"; - bookmarksAllButton.setAttribute("aria-pressed", String(panelState.showingAllBookmarks)); - renderBookmarks(); -}); -for (const button of document.querySelectorAll(".panel-search-toggle")) { - button.classList.remove("icon-button"); - button.classList.add("text-action"); - button.textContent = "搜索"; - button.hidden = false; - button.setAttribute("aria-expanded", "false"); - button.addEventListener("click", () => - setPanelSearchOpen(button, button.getAttribute("aria-expanded") !== "true") - ); -} -for (const input of document.querySelectorAll(".panel-search")) { - const view = input.closest(".reader-panel-view"); - input.type = "search"; - input.setAttribute( - "aria-label", - `搜索${view.querySelector(".panel-view-header strong")?.textContent || "列表"}` - ); - input.addEventListener("input", (event) => { - if (!event.isComposing) filterPanel(view); - }); - input.addEventListener("compositionend", () => filterPanel(view)); -} -document.querySelector("#history-clear").addEventListener("click", async () => { - if (!confirm("清空全部阅读历史?")) return; - historySuppressed = true; - await progressSaveChain; - await VoiceOfMLReaderStore.clearHistory(); - renderHistory(); -}); -readerThemeToggle.addEventListener("click", () => { - const theme = readerTheme === "dark" ? "light" : "dark"; - applyReaderTheme(theme); - if (window.parent !== window) - window.parent.postMessage({ type: "voice-reader-theme", theme }, location.origin); -}); -window.addEventListener("storage", (event) => { - if (event.key === "theme" && event.newValue) applyReaderTheme(event.newValue, false); -}); -window.addEventListener("voice-reader-dispose", () => { - saveProgress(); - disposeReader(); -}); -window.addEventListener("message", (event) => { - if ( - event.origin === location.origin && - event.source === window.parent && - event.data?.type === "voice-reader-abort" - ) { - saveProgress(); - disposeReader(); - return; - } - if ( - event.origin === location.origin && - event.source === window.parent && - event.data && - event.data.type === "voice-reader-theme-state" - ) - applyReaderTheme(event.data.theme, false); - if (event.origin === location.origin && event.source === window.parent && - event.data?.type === "voice-reader-system-back") { - if (readerUiHistoryState === "full-search") closeFullSearchView(); - else if (readerUiHistoryState === "panel") setReaderPanelOpen(false, true); - } -}); -document - .querySelector("#history") - .addEventListener("click", () => - setReaderPanelOpen(document.querySelector("#history").getAttribute("aria-expanded") !== "true") - ); -document - .querySelector("#history-close") - .addEventListener("click", () => setReaderPanelOpen(false, true)); -viewport.addEventListener("scroll", handleReaderPositionChange, { passive: true }); -function notePdfScrollIntent() { - pdfUserHasScrolled = true; - beginReaderNavigation(); -} -for (const type of ["wheel", "touchstart", "pointerdown"]) - viewport.addEventListener(type, notePdfScrollIntent, { passive: true }); +readerBindings.bindPanelBookmarkAndLifecycleControls(); let pdfSelectionPointer = null; -function installReaderTapSelectionClear(region, eventRoot = region.ownerDocument) { - let tap = null; - const down = (event) => { - tap = null; - if (event.pointerType !== "touch" || !event.isPrimary) return; - const target = event.composedPath()[0]; - if (target.closest?.("a,button,input,textarea,select,[contenteditable]")) return; - const root = target.getRootNode(); - const selection = root.getSelection?.() || eventRoot.getSelection(); - if (!selection || selection.isCollapsed || !selection.rangeCount) return; - tap = { id: event.pointerId, x: event.clientX, y: event.clientY, time: performance.now(), - selection, anchor: selection.anchorNode, anchorOffset: selection.anchorOffset, - focus: selection.focusNode, focusOffset: selection.focusOffset }; - }; - const move = (event) => { - if (tap && event.pointerId === tap.id && Math.hypot(event.clientX - tap.x, event.clientY - tap.y) > 8) - tap = null; - }; - const up = (event) => { - const start = tap; - tap = null; - if (!start || event.pointerId !== start.id || performance.now() - start.time > 300 || - Math.hypot(event.clientX - start.x, event.clientY - start.y) > 8) return; - const selection = start.selection; - if (selection.anchorNode === start.anchor && selection.anchorOffset === start.anchorOffset && - selection.focusNode === start.focus && selection.focusOffset === start.focusOffset) - selection.removeAllRanges(); - }; - const cancel = () => { tap = null; }; - region.addEventListener("pointerdown", down, { passive: true }); - eventRoot.addEventListener("pointermove", move, { passive: true }); - eventRoot.addEventListener("pointerup", up, { passive: true }); - eventRoot.addEventListener("pointercancel", cancel, { passive: true }); - trackReaderResource(() => { - region.removeEventListener("pointerdown", down); - eventRoot.removeEventListener("pointermove", move); - eventRoot.removeEventListener("pointerup", up); - eventRoot.removeEventListener("pointercancel", cancel); - tap = null; - }); -} -installReaderTapSelectionClear(viewport); -viewport.addEventListener("pointerdown", (event) => { - if (event.pointerType !== "mouse") { - pdfSelectionPointer = null; - return; - } - const run = event.target.closest?.(".reader-pdf-text-run"); - const selection = document.getSelection(); - const selectedRun = !selection?.isCollapsed && event.target.closest?.(".reader-page") - ? selection.anchorNode?.parentElement?.closest(".reader-pdf-text-run") : null; - const origin = run || selectedRun; - const caret = !event.shiftKey && event.button === 0 ? independentTextCaret(document, event.clientX, event.clientY) : null; - pdfSelectionPointer = origin - ? { id: event.pointerId, x: event.clientX, y: event.clientY, frame: 0, - page: origin.closest(".reader-page"), anchorNode: caret?.node || (run ? null : selection.anchorNode), - anchorOffset: caret?.offset ?? (run ? 0 : selection.anchorOffset) } : null; -}, { passive: true }); -document.addEventListener("pointermove", (event) => { - const start = pdfSelectionPointer; - if (event.pointerType !== "mouse" || !start || start.id !== event.pointerId || - Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) return; - const selection = document.getSelection(); - if (!start.anchorNode && selection?.rangeCount && start.page.contains(selection.anchorNode) && - selection.anchorNode.parentElement?.closest(".reader-pdf-text-run")) { - start.anchorNode = selection.anchorNode; - start.anchorOffset = selection.anchorOffset; - } - if (start.anchorNode) { - start.endX = event.clientX; - start.endY = event.clientY; - if (!start.frame) start.frame = requestAnimationFrame(() => { - start.frame = 0; - if (pdfSelectionPointer === start) - correctPdfCrossPageSelection(start.endX, start.endY, start); - }); - } -}, { passive: true }); -document.addEventListener("pointerup", (event) => { - const start = pdfSelectionPointer; - pdfSelectionPointer = null; - if (event.pointerType !== "mouse" || !["pdf", "pdf-pages"].includes(capability.mode) || - !start || start.id !== event.pointerId || - Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) return; - const x = event.clientX, y = event.clientY; - requestAnimationFrame(() => correctPdfCrossPageSelection(x, y, start)); -}, { passive: true }); -document.addEventListener("pointercancel", () => { pdfSelectionPointer = null; }, { passive: true }); -let pdfSelectionBoundaryUpdate = false; -document.addEventListener("selectionchange", () => { - if (pdfSelectionBoundaryUpdate || - !["pdf", "pdf-pages"].includes(capability.mode)) return; - const selection = document.getSelection(); - if (!selection || selection.isCollapsed || !selection.rangeCount) return; - const runs = [...content.querySelectorAll(".reader-pdf-text-run")] - .filter((run) => run.firstChild?.nodeType === Node.TEXT_NODE); - if (!runs.length) return; - const anchorRun = selection.anchorNode?.parentElement?.closest(".reader-pdf-text-run"); - const focusRun = selection.focusNode?.parentElement?.closest(".reader-pdf-text-run"); - if (!anchorRun && !focusRun) return; - if (focusRun) return; - const beforeAnchor = selection.focusNode && selection.anchorNode && - (selection.focusNode === selection.anchorNode - ? selection.focusOffset < selection.anchorOffset - : Boolean(selection.focusNode.compareDocumentPosition(selection.anchorNode) & Node.DOCUMENT_POSITION_FOLLOWING)); - const target = beforeAnchor ? runs[0] : runs[runs.length - 1]; - const node = target.firstChild; - pdfSelectionBoundaryUpdate = true; - try { - selection.setBaseAndExtent(selection.anchorNode, selection.anchorOffset, node, - beforeAnchor ? 0 : node.length); - } finally { - pdfSelectionBoundaryUpdate = false; - } -}, { passive: true }); -window.addEventListener("pagehide", (event) => { - saveProgress(event); - if (!event.persisted) disposeReader(); -}); -window.addEventListener("pageshow", (event) => { - if (event.persisted && !readerLifecycle.disposed) { - scheduleFoliateScrollSync(); - updateProgressTools(); - } -}); -document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "hidden") saveProgress(); - if (document.hidden) stopPdfManifestPrefetch(); - else queuePdfManifestPrefetch(); -}); -window.addEventListener("offline", stopPdfManifestPrefetch); -window.addEventListener("online", () => queuePdfManifestPrefetch()); -function validSource(raw) { - try { - const url = new URL(raw); - const bucketPath = url.searchParams.get("path") || ""; - if ( - (url.origin === location.origin || url.origin === "https://voiceofml-search.hf.space") && - url.pathname === "/api/reader-bucket-resource" && - url.searchParams.getAll("path").length === 1 && - [...url.searchParams.keys()].every((key) => key === "path") && - (VoiceOfMLReader.isBucketPath(bucketPath, true, "vomebook/pdf-pages-v2") || - VoiceOfMLReader.isBucketPath(bucketPath, true, "vomebook/reader-assets-v2")) - ) - return true; - if (url.protocol === "https:" && url.hostname === "huggingface.co" && - (url.pathname.startsWith("/buckets/vomebook/pdf-pages-v2/resolve/") || - url.pathname.startsWith("/buckets/vomebook/reader-assets-v2/resolve/")) && !url.search && !url.hash) { - const prefix = url.pathname.startsWith("/buckets/vomebook/pdf-pages-v2/resolve/") - ? "/buckets/vomebook/pdf-pages-v2/resolve/" - : "/buckets/vomebook/reader-assets-v2/resolve/"; - const path = decodeURIComponent(url.pathname.slice(prefix.length)); - const bucket = prefix.includes("pdf-pages-v2") ? "vomebook/pdf-pages-v2" : "vomebook/reader-assets-v2"; - return VoiceOfMLReader.isBucketPath(path, true, bucket); - } - if (url.protocol !== "https:" || !["huggingface.co", "hf-mirror.com"].includes(url.hostname)) - return false; - const readerAsset = VoiceOfMLReader.isAssetSourcePath(url.pathname); - if (extension === "docx") return readerAsset; - return /^\/datasets\/VoiceOfML\/[^/]+\/(resolve|raw)\//.test(url.pathname) || readerAsset; - } catch (_) { - return false; - } -} -function validOcr(raw) { - try { - const url = new URL(raw, location.origin); - return url.origin === location.origin && url.pathname.startsWith("/txt/"); - } catch (_) { - return false; - } -} -function validFallback(raw) { - try { - const url = new URL(raw); - return ( - url.protocol === "https:" && - ["huggingface.co", "hf-mirror.com"].includes(url.hostname) && - /\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:linearized\.pdf|(?:[a-z0-9-]+\/)?document\.pdf)$/.test( - url.pathname - ) - ); - } catch (_) { - return false; - } -} -function readerAssetObjectFamily(raw, base = location.href) { - try { - const url = new URL(raw, base); - if (url.origin === "https://voiceofml-search.hf.space" && url.pathname === "/api/reader-bucket-resource") { - const bucketPath = url.searchParams.get("path") || ""; - const match = bucketPath.match(/^(chapters\/ebook\/(?:epub|mobi|azw3|fb2|chm)\/[0-9a-f]{64}\/[0-9a-f]{16}\/)/); - if (url.hash || url.searchParams.getAll("path").length !== 1 || - [...url.searchParams.keys()].some((key) => key !== "path") || !match || - !VoiceOfMLReader.isBucketPath(bucketPath, true, "vomebook/reader-assets-v2")) return null; - return { url, prefix: match[1], bucket: true, bucketPath }; - } - if ( - url.protocol !== "https:" || - !["huggingface.co", "hf-mirror.com"].includes(url.hostname) || - url.username || - url.password - ) - return null; - const match = url.pathname.match( - /^\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/(objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[a-z0-9-]+\/)?)/ - ); - return match - ? { url, prefix: url.pathname.slice(0, url.pathname.indexOf(match[1])) + match[1], bucket: false } - : null; - } catch (_) { - return null; - } -} -function trustedReaderAssetManifestUrl(raw) { - const family = readerAssetObjectFamily(raw); - const manifestPath = family?.bucket ? family.bucketPath : family?.url.pathname; - return family && - (family.bucket || !family.url.search) && - (family.bucket || !family.url.hash) && - /\/chapter-manifest\.json$/i.test(manifestPath || "") - ? family.url.href - : null; -} -function trustedChapterUrl(raw, base) { - const manifest = readerAssetObjectFamily(base); - const manifestPath = manifest?.bucket ? manifest.bucketPath : new URL(base, location.href).pathname; - if (!manifest || !/\/chapter-manifest\.json$/i.test(manifestPath)) - return null; - try { - if (manifest.bucket) { - const url = new URL(String(raw || ""), `https://bucket.invalid/${manifest.bucketPath}`); - const path = decodeURIComponent(url.pathname.slice(1)); - if (url.origin !== "https://bucket.invalid" || url.search || url.hash || - !path.startsWith(manifest.prefix) || - !/(?:\/chapters\/chapter-[0-9]{4}\.xhtml|\/epub-search-index\.json\.gz)$/i.test(path) || - !VoiceOfMLReader.isBucketPath(path, true, "vomebook/reader-assets-v2")) return null; - return `${manifest.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`; - } - const url = new URL(raw, base); - return !url.search && - !url.hash && - url.origin === manifest.url.origin && - url.pathname.startsWith(manifest.prefix) && - /(?:\.(?:xhtml?|html?)|\/epub-search-index\.json\.gz)$/i.test(url.pathname) - ? url.href - : null; - } catch (_) { - return null; - } -} -function trustedChapterReferenceUrl(raw, base, manifestBase) { - const family = readerAssetObjectFamily(manifestBase); - if (!family) return null; - try { - if (family.bucket) { - const current = readerAssetObjectFamily(base); - if (!current?.bucket) return null; - const url = new URL(String(raw || ""), `https://bucket.invalid/${current.bucketPath}`); - const path = decodeURIComponent(url.pathname.slice(1)); - if (url.origin !== "https://bucket.invalid" || url.search || - !path.startsWith(family.prefix) || !/\/chapters\/chapter-[0-9]{4}\.xhtml$/i.test(path) || - !VoiceOfMLReader.isBucketPath(path, true, "vomebook/reader-assets-v2")) return null; - return { url: `${family.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`, - fragment: url.hash }; - } - const url = new URL(raw, base); - return !url.search && url.origin === family.url.origin && url.pathname.startsWith(family.prefix) && - /\/(?:chapters\/)?[^/]+\.xhtml?$/i.test(url.pathname) - ? { url: url.href, fragment: url.hash } : null; - } catch (_) { - return null; - } -} -function trustedChapterResourceUrl(raw, base, attribute, manifestBase) { - const value = String(raw || "").trim(); - if (!value || /^(?:data:|mailto:|tel:|javascript:)/i.test(value)) return null; - // Chapters and resources can be siblings within the manifest's asset directory. - const family = readerAssetObjectFamily(manifestBase); - if (!family) return null; - try { - if (family.bucket) { - const current = readerAssetObjectFamily(base); - if (!current?.bucket) return null; - const url = new URL(value, `https://bucket.invalid/${current.bucketPath}`); - const path = decodeURIComponent(url.pathname.slice(1)); - if (url.origin !== "https://bucket.invalid" || url.search || url.hash || - !path.startsWith(family.prefix) || !VoiceOfMLReader.isBucketPath(path, true, "vomebook/reader-assets-v2")) return null; - const resource = `${family.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`; - return attribute === "href" ? resource : resource; - } - const url = new URL(value, base), - path = url.pathname.toLowerCase(); - if (url.origin !== family.url.origin || !url.pathname.startsWith(family.prefix)) return null; - const allowed = - attribute === "href" - ? /\.(?:x?html?|css|svg|png|jpe?g|gif|webp|woff2?|ttf|otf|mp3|mp4|m4a|webm|ogg)$/i.test( - path - ) - : /\.(?:svg|png|jpe?g|gif|webp|woff2?|ttf|otf|mp3|mp4|m4a|webm|ogg)$/i.test(path); - return allowed ? url.href : null; - } catch (_) { - return null; - } -} -function loadScript(url) { - assertReaderActive(); - return new Promise((resolve, reject) => { - const script = document.createElement("script"); - let settled = false, timeout = 0, untrack = () => {}; - const finish = (error) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - untrack(); - script.onload = script.onerror = null; - if (error) { - script.remove(); - reject(error); - } else resolve(); - }; - const networkError = () => Object.assign( - new Error("阅读组件下载失败,请检查网络后重试加载,或下载原文件。"), - { code: "READER_ENGINE_NETWORK" } - ); - untrack = trackReaderResource(() => finish(readerAbortError())); - // Download sibling engines concurrently, execute in dependency order. - script.async = false; - script.src = url; - script.onload = () => finish(); - script.onerror = () => finish(networkError()); - timeout = setTimeout(() => finish(networkError()), 20000); - document.head.appendChild(script); - }); -} -function fetchWithReaderTimeout(url, timeoutMs = READER_PROXY_TIMEOUT_MS, requestInit = {}) { - return readerRequestManager.request(url, timeoutMs, requestInit); -} -function retryableReaderProxyError(error) { - return [408, 429, 500, 502, 503, 504].includes(error?.status) || - ["TypeError", "TimeoutError"].includes(error?.name) || - /failed to fetch|network|timeout|HTTP\s*(?:408|429|5\d\d)|server response\s*\((?:408|429|5\d\d)\)/i.test(error?.message || ""); -} -async function retryReaderProxy(open) { - for (let attempt = 0; ; attempt++) { - assertReaderActive(); - try { - return await open(); - } catch (error) { - if (attempt >= 2 || !retryableReaderProxyError(error)) throw error; - await waitForReader(350 * (attempt + 1)); - } - } -} -function fetchReaderUrl(rawUrl, requestInit = {}) { - if (String(rawUrl).includes("/api/reader-bucket-resource?")) - return fetchWithReaderTimeout(rawUrl, READER_PROXY_TIMEOUT_MS, requestInit); - const proxyUrl = readerContentUrl(rawUrl); - return retryReaderProxy(async () => { - const response = await fetchWithReaderTimeout(proxyUrl, READER_PROXY_TIMEOUT_MS, requestInit); - if (response.ok) return response; - await response.body?.cancel(); - throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status }); - }).catch((error) => { - assertReaderActive(); - requestInit.signal?.throwIfAborted(); - if (retryableReaderProxyError(error)) throw error; - return fetchWithReaderTimeout(rawUrl, READER_PROXY_TIMEOUT_MS, requestInit); - }); -} -function fetchReaderResponse() { - const early = window.__VOICE_PDF_PRELOAD__; - if (early?.manifest && early.manifestUrl === new URL(sourceUrl, location.href).href) { - const pending = early.manifest; - early.manifest = null; - return pending.then( - (response) => (response?.ok ? response : fetchReaderUrl(sourceUrl)), - () => fetchReaderUrl(sourceUrl) - ); - } - return fetchReaderUrl(sourceUrl); -} -async function preloadPdfFirstPage(total) { - if (pdfV3Manifest) return; - await getInitialReaderRestoration(); - try { - const source = pdfManifestSource(); - if (!source) return; - const page = initialReaderPage(total); - pdfFirstPagePreloadPage = page; - const pageUrl = new URL(source.pageUrl(page)); - const early = window.__VOICE_PDF_PRELOAD__; - if (early?.image && early.pageUrl === pageUrl.href) { - pdfFirstPagePreload = early.image; - // Reuse an existing request at demand priority when available. - pdfFirstPagePreload.fetchPriority = "high"; - } - else if (!navigator.connection?.saveData) { - pdfFirstPagePreload = new Image(); - pdfFirstPagePreload.decoding = "async"; - pdfFirstPagePreload.fetchPriority = "high"; - pdfFirstPagePreload.src = pageUrl.href; - } - } catch (_) {} -} -function pdfManifestSource() { - if (String(sourceUrl).includes("reading-manifest.json")) - return VoiceOfMLReader.pdfPageSource(sourceUrl, location.href, location.origin, "reading-manifest.json"); - return capability.mode === "image-pages" - ? VoiceOfMLReader.imagePageSource(sourceUrl, location.href, location.origin) - : VoiceOfMLReader.pdfPageSource(sourceUrl, location.href, location.origin); -} -function pdfPageEntry(page) { - if (!pdfPageManifest || page < 1 || page > pdfPageManifest.pageCount) return null; - const pageEntry = ( - pdfPageManifest.entries?.[page - 1] || { - page, - path: `${pdfPageManifest.root}/pages/page-${String(page).padStart(6, "0")}.webp` - } - ); - const ocrEntry = pdfPageManifest.ocr?.[page - 1]; - return ocrEntry ? { ...pageEntry, ...ocrEntry, page } : pageEntry; -} -function takePdfManifestPrefetch(target) { - const record = pdfManifestPrefetches.get(target); - if (!record) return null; - pdfManifestPrefetches.delete(target); - pdfManifestPrefetchActive.delete(record); - clearTimeout(record.timeout); - record.image.onload = record.image.onerror = null; - return record.image; -} -function queuePdfManifestPrefetch(delay = 250) { - if (readerAbortController.signal.aborted || !pdfManifestPrefetchOrigin || !canPrefetchPdfImages()) return; - clearTimeout(pdfManifestPrefetchTimer); - pdfManifestPrefetchTimer = setTimeout(() => { - pdfManifestPrefetchTimer = 0; - if (readerAbortController.signal.aborted || document.hidden || !navigator.onLine || - pdfManifestPrefetchActive.size >= pdfManifestPrefetchConcurrency() || - pdfManifestPrefetchCount >= pdfManifestPrefetchMaxPages() || - (pdfActiveRenders && content.querySelector('.reader-page[data-render-visible="1"][data-render-state="rendering"]'))) return; - while (pdfManifestPrefetchActive.size < pdfManifestPrefetchConcurrency()) { - if (pdfManifestPrefetchCount >= pdfManifestPrefetchMaxPages()) return; - if (pdfManifestPrefetchWrapped && pdfManifestPrefetchNext >= pdfManifestPrefetchOrigin) return; - if (pdfManifestPrefetchNext > documentState.pageCount) { - pdfManifestPrefetchNext = 1; - pdfManifestPrefetchWrapped = true; - } - const next = pdfManifestPrefetchNext++; - const shell = pdfManifestShells[next]; - if (shell && ["rendering", "rendered"].includes(shell.dataset.renderState)) continue; - const url = pdfPageManifest.pageUrl(next); - if (pdfManifestPrefetches.has(url)) continue; - const image = new Image(); - const record = { image, url, timeout: 0 }; - image.decoding = "async"; - image.fetchPriority = "low"; - const finish = (failed) => { - if (!pdfManifestPrefetchActive.has(record)) return; - pdfManifestPrefetchActive.delete(record); - clearTimeout(record.timeout); - image.onload = image.onerror = null; - if (failed) { - pdfManifestPrefetches.delete(url); - image.removeAttribute("src"); - } - trimPdfManifestPrefetches(); - queuePdfManifestPrefetch(); - }; - image.onload = () => finish(false); - image.onerror = () => finish(true); - pdfManifestPrefetches.set(url, record); - pdfManifestPrefetchActive.add(record); - pdfManifestPrefetchCount++; - record.timeout = setTimeout(() => finish(true), 15000); - image.src = url; - trimPdfManifestPrefetches(); - } - }, delay); -} -function prioritizePdfManifestPrefetch(target) { - for (const record of [...pdfManifestPrefetchActive]) { - if (record.url !== target) cancelPdfManifestPrefetch(record); - } - const record = pdfManifestPrefetches.get(target); - if (record && pdfManifestPrefetchActive.has(record)) record.image.fetchPriority = "high"; -} -function schedulePdfManifestPrefetch(page) { - if (pdfHybridUrl) return; - if (!["pdf-pages", "image-pages"].includes(capability.mode) || !pdfPageManifest) return; - const origin = Math.max(1, Math.min(documentState.pageCount + 1, page + 1)); - if (pdfManifestPrefetchOrigin === origin && pdfManifestPrefetchNext) { - if (!pdfManifestPrefetchActive.size && !pdfManifestPrefetchTimer) queuePdfManifestPrefetch(); - return; - } - stopPdfManifestPrefetch(); - pdfManifestPrefetchOrigin = pdfManifestPrefetchNext = origin; - pdfManifestPrefetchWrapped = false; - queuePdfManifestPrefetch(); -} - -function pdfOcrSource() { - return VoiceOfMLReader.pdfPageSource(ocrManifestUrl, location.href, location.origin, "ocr-manifest.json"); -} -function v3ResourceUrl(ref) { - validateResource(ref); - return new URL(readerDownloadApiUrl(`/api/reader-bucket-resource?path=${encodeURIComponent(ref.path)}`), location.href).href; -} -function createPdfV3Repository(sourceSha, pageCount) { - return createV3Repository({ sourceSha, pageCount, signal: readerAbortController.signal, - resourceUrl: v3ResourceUrl, - readJson: (url, ref) => readPdfOcrJson(url, VoiceOfMLReaderSecurity.LIMITS.chapterBytes, ref.sha256, ref.bytes), - readBytes: async (url, ref) => { - const response = await fetchReaderUrl(url); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const packed = await VoiceOfMLReaderSecurity.readBytes(response, VoiceOfMLReaderSecurity.LIMITS.chapterBytes); - const actual = [...new Uint8Array(await crypto.subtle.digest("SHA-256", packed))] - .map(value => value.toString(16).padStart(2, "0")).join(""); - if (packed.byteLength !== ref.bytes || actual !== ref.sha256) throw new Error("PDF_V3_HASH_MISMATCH"); - return packed; - } - }); -} -async function readPdfOcrJson(url, limit = VoiceOfMLReaderSecurity.LIMITS.chapterBytes, digest = "", size = 0, priority = "auto") { - const response = await fetchReaderUrl(url, { priority }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - let bytes = await VoiceOfMLReaderSecurity.readBytes(response, limit); - if (size && bytes.byteLength !== size) throw new Error("PDF_OCR_SIZE_MISMATCH"); - if (digest) { - const actual = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))] - .map((value) => value.toString(16).padStart(2, "0")).join(""); - if (actual !== digest) throw new Error("PDF_OCR_HASH_MISMATCH"); - } - const parsed = new URL(url, location.href); - if (/\.gz$/i.test(parsed.searchParams.get("path") || parsed.pathname)) { - if (typeof DecompressionStream !== "function") throw new Error("PDF_OCR_GZIP_UNSUPPORTED"); - const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); - bytes = await VoiceOfMLReaderSecurity.readBytes(new Response(stream), limit); - } - return JSON.parse(new TextDecoder().decode(bytes)); -} - -async function loadPdfOcrManifest() { - if (pdfOcrManifest) return pdfOcrManifest; - if (!ocrManifestUrl) return null; - if (!pdfOcrManifestPromise) { - if (!pdfOcrSource()) throw new Error("PDF_OCR_SOURCE_INVALID"); - pdfOcrManifestPromise = readPdfOcrJson(ocrManifestUrl, VoiceOfMLReaderSecurity.LIMITS.manifestBytes, "", 0, "high").then((manifest) => { - assertReaderActive(); - if (manifest.page_count !== documentState.pageCount) throw new Error("PDF_OCR_PAGE_COUNT_MISMATCH"); - pdfOcrManifest = VoiceOfMLReaderSecurity.validatePdfOcrManifest(manifest); - return pdfOcrManifest; - }); - } - try { return await pdfOcrManifestPromise; } - catch (error) { pdfOcrManifestPromise = null; throw error; } -} -function loadPdfOcrPage(page, priority = "high") { - const cached = cachedPdfOcrPage(page); - if (cached) return Promise.resolve(cached); - const pending = pdfOcrPagePromises.get(page); - if (pending) return pending; - const task = Promise.resolve().then(() => { - // OCR is published as its own manifest. The image page manifest may be - // intentionally lean and contain only page_count. - return loadPdfOcrManifest().then((manifest) => { - if (manifest?.text_layer) { - pdfTextLayerRepository ||= createPdfV3Repository(manifest.source_sha256, manifest.page_count); - return pdfTextLayerRepository.textPage(manifest.text_layer, page).then(payload => { - if (pdfV3Map) { - const geometry = pdfV3Map.pages[page - 1]; - if (Math.abs((payload.geometry.width / payload.geometry.height) / (geometry.width / geometry.height) - 1) > .01) - throw new Error("PDF_V3_TEXT_GEOMETRY_MISMATCH"); - } - assertReaderActive(); cachePdfOcrPage(page, payload); return payload; - }); - } - const entry = manifest?.pages?.[page - 1]; - if (!entry || entry.p !== page) throw new Error("PDF_OCR_PAGE_MISSING"); - return readPdfOcrJson(pdfOcrSource().assetUrl(entry.o), - VoiceOfMLReaderSecurity.LIMITS.chapterBytes, entry.os, entry.ob, priority).then((payload) => { - VoiceOfMLReaderSecurity.validatePdfOcrPage(payload, page); - cachePdfOcrPage(page, payload); - return payload; - }); - }); - }).finally(() => pdfOcrPagePromises.delete(page)); - pdfOcrPagePromises.set(page, task); - return task; -} -function estimatePdfTextContentBytes(content) { - let bytes = 0; - for (const item of content?.items || []) - bytes += String(item.str || "").length * 2 + (item.transform?.length || 0) * 8 + 64; - for (const style of Object.values(content?.styles || {})) - bytes += Object.entries(style || {}).reduce( - (total, [key, value]) => total + key.length + String(value).length * 2 + 16, 0 - ); - return Math.max(128, bytes); -} -function cachePdfTextContent(page, content) { - const bytes = estimatePdfTextContentBytes(content); - if (bytes > PDF_TEXT_CONTENT_CACHE_BYTES) return; - const previous = pdfTextContentCache.get(page); - if (previous) pdfTextContentCacheBytes -= previous.bytes; - pdfTextContentCache.delete(page); - pdfTextContentCache.set(page, { content, bytes }); - pdfTextContentCacheBytes += bytes; - while (pdfTextContentCacheBytes > PDF_TEXT_CONTENT_CACHE_BYTES) { - const oldest = pdfTextContentCache.keys().next().value; - const removed = pdfTextContentCache.get(oldest); - pdfTextContentCacheBytes -= removed.bytes; - pdfTextContentCache.delete(oldest); - } -} -function cachedPdfTextContent(page) { - const record = pdfTextContentCache.get(page); - if (!record) return null; - pdfTextContentCache.delete(page); - pdfTextContentCache.set(page, record); - return record.content; -} -function loadPdfTextContent(page, pdfPage, generation = null) { - assertReaderActive(); - const cached = cachedPdfTextContent(page); - if (cached) return Promise.resolve(cached); - const pending = pdfTextContentPromises.get(page); - if (pending) { - if (generation === null) pending.request.demanded = true; - else pending.request.generation = generation; - drainPdfTextWaiters(); - return pending; - } - // Rendering and search share extraction, but retain their own cancellation - // and result ownership. Only active work lives here, not another text cache. - const request = { demanded: generation === null, generation, page }; - const task = Promise.resolve().then(async () => { - const background = await acquirePdfTextSlot(request.demanded, request); - try { - assertReaderActive(); - if (!request.demanded && !isReaderGenerationCurrent("search", request.generation)) throw readerAbortError(); - const text = await awaitReader(pdfPage.getTextContent()); - cachePdfTextContent(page, text); - return text; - } finally { - releasePdfTextSlot(background); - } - }) - .finally(() => { - if (pdfTextContentPromises.get(page) === task) pdfTextContentPromises.delete(page); - }); - task.request = request; - pdfTextContentPromises.set(page, task); - return task; -} -function estimatePdfOcrPageBytes(payload) { - if (payload?.kind === "pdf-text-layer") return Math.max(128, JSON.stringify(payload).length * 2); - return Math.max(128, (payload?.blocks || []).reduce( - (total, block) => total + String(block?.t || "").length * 2 + 48, 0 - )); -} -function cachePdfOcrPage(page, payload) { - const bytes = estimatePdfOcrPageBytes(payload); - if (bytes > PDF_OCR_PAGE_CACHE_BYTES) return; - const previous = pdfOcrPageCache.get(page); - if (previous) pdfOcrPageCacheBytes -= previous.bytes; - pdfOcrPageCache.delete(page); - pdfOcrPageCache.set(page, { payload, bytes }); - pdfOcrPageCacheBytes += bytes; - while (pdfOcrPageCacheBytes > PDF_OCR_PAGE_CACHE_BYTES) { - const oldest = pdfOcrPageCache.keys().next().value; - const removed = pdfOcrPageCache.get(oldest); - pdfOcrPageCacheBytes -= removed.bytes; - pdfOcrPageCache.delete(oldest); - } -} -function cachedPdfOcrPage(page) { - const record = pdfOcrPageCache.get(page); - if (!record) return null; - pdfOcrPageCache.delete(page); - pdfOcrPageCache.set(page, record); - return record.payload; -} - -async function renderPdfOcrText(shell) { - if (shell.dataset.textReady === "1" || !ocrManifestUrl) return; - if (shell._ocrPromise) return shell._ocrPromise; - const layer = shell.querySelector(".reader-pdf-text"); - if (!layer) return; - const epoch = shell._textEpoch || 0; - const task = (async () => { - try { - const page = Number(shell.dataset.page); - await acquirePdfTextSlot(isPdfPageVisible(shell)); - try { - if (epoch !== (shell._textEpoch || 0)) return; - const payload = await loadPdfOcrPage(page, isPdfPageVisible(shell) ? "high" : "low"); - assertReaderActive(); - if (epoch !== (shell._textEpoch || 0)) return; - if (payload.kind === "pdf-text-layer") { - populateIndependentTextLayer(layer, payload); - shell._bookmarkTextItems = payload.regions.map(region => ({ text: region.text, y: region.box[1] })); - } else { - VoiceOfMLReaderSecurity.validatePdfOcrPage(payload, page); - populateOcrTextLayer(layer, payload.blocks, payload.layout); - shell._bookmarkTextItems = payload.blocks.map((block) => ({ text: block.t, y: block.b?.[1] || 0 })); - } - shell.dataset.textReady = "1"; - highlightPdfText(shell); - } finally { - releasePdfTextSlot(); - } - } catch (error) { - if (!readerAbortController.signal.aborted) - console.warn(`PDF page ${shell.dataset.page} OCR text failed`, error); - } - })().finally(() => { if (shell._ocrPromise === task) delete shell._ocrPromise; }); - shell._ocrPromise = task; - return task; -} -function trimPdfManifestImages(protectedShell) { - trimPdfSurfaces(protectedShell); -} -const EPUB_HTML_TAGS = new Set( - "a,abbr,address,area,article,aside,audio,b,base,bdi,bdo,blockquote,body,br,button,canvas,caption,cite,code,col,colgroup,data,datalist,dd,del,details,dfn,dialog,div,dl,dt,em,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,i,iframe,img,input,ins,kbd,label,legend,li,link,main,map,mark,menu,meta,meter,nav,noscript,object,ol,optgroup,option,output,p,picture,pre,progress,q,rb,rp,rt,rtc,ruby,s,samp,script,search,section,select,slot,small,source,span,strong,style,sub,summary,sup,table,tbody,td,template,textarea,tfoot,th,thead,time,title,tr,track,u,ul,var,video,wbr".split( - "," - ) -); -const EPUB_HTML_VOID_TAGS = new Set( - "area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr".split(",") -); - -function normalizeEpubDocument(doc) { - // Legacy CHM conversions can use an XHTML prefix even in HTML content. - for (const element of [...doc.querySelectorAll("*")]) { - const tagName = String(element.tagName || "").toLowerCase(); - const localName = tagName.includes(":") ? tagName.slice(tagName.lastIndexOf(":") + 1) : tagName; - if (tagName === localName || !EPUB_HTML_TAGS.has(localName)) continue; - const replacement = doc.createElementNS("http://www.w3.org/1999/xhtml", localName); - for (const attribute of [...element.attributes]) { - if (!attribute.name.toLowerCase().startsWith("xmlns:")) - replacement.setAttribute(attribute.name, attribute.value); - } - // HTML parsing treats prefixed void tags as containers. Keep the following - // content as siblings: serializing real
/
/ drops their children. - // Move the existing text nodes so Foliate's source/CFI mapping stays intact. - const fragment = doc.createDocumentFragment(); - fragment.appendChild(replacement); - if (EPUB_HTML_VOID_TAGS.has(localName)) { - while (element.firstChild) fragment.appendChild(element.firstChild); - } else { - while (element.firstChild) replacement.appendChild(element.firstChild); - } - element.replaceWith(fragment); - } - return doc; -} - -function sanitizeReaderDocument(doc, { preserveEpubStyles = false } = {}) { - const blocked = preserveEpubStyles - ? "script,iframe,object,embed,base,form" - : "script,iframe,object,embed,base,form,link,style"; - for (const node of doc.querySelectorAll(blocked)) node.remove(); - if (preserveEpubStyles) { - for (const link of doc.querySelectorAll("link")) { - const rel = (link.getAttribute("rel") || "").toLowerCase().split(/\s+/); - if (!rel.includes("stylesheet") || !/^blob:/i.test(link.getAttribute("href") || "")) - link.remove(); - } - for (const style of doc.querySelectorAll("style")) { - if ( - /(?:@import\s+|url\s*\(\s*[\"']?)(?:https?:|\/\/|javascript:)/i.test( - style.textContent || "" - ) - ) - style.remove(); - } - } - for (const element of doc.querySelectorAll("*")) - for (const attribute of [...element.attributes]) { - const name = attribute.name.toLowerCase(), - value = String(attribute.value || "").trim(); - if ( - name.startsWith("on") || - name === "srcdoc" || - ((name === "href" || name === "xlink:href" || name === "src") && - /^javascript:/i.test(value)) - ) - element.removeAttribute(attribute.name); - } - return doc; -} - -function sanitizeEpubDocument(doc) { - return sanitizeReaderDocument(normalizeEpubDocument(doc), { preserveEpubStyles: true }); -} -function parseChapterDocument(text) { - // Parse generated XHTML before HTML's void-element rules can swallow children - // of prefixed elements such as (including the following title). - const xml = new DOMParser().parseFromString(text, "application/xhtml+xml"); - if (!xml.querySelector("parsererror")) - text = new XMLSerializer().serializeToString(normalizeEpubDocument(xml)); - return sanitizeEpubDocument(new DOMParser().parseFromString(text, "text/html")); -} - -function epubContentBody(doc) { - const nestedBody = [...doc.querySelectorAll("body")].find((body) => body !== doc.body); - return nestedBody || doc.body || doc.documentElement; -} -function disposeReader() { - if (readerLifecycle.disposed) return; - updateDocumentState({ restorationReady: false }); - readerRuntime.dispose(); - contentSanitizerObserver?.disconnect(); - contentSanitizerObserver = null; - clearTimeout(saveTimer); - clearTimeout(themeAnimationTimer); - clearTimeout(panelAnimationTimer); - for (const frame of [markerFrame, foliateScrollFrame, foliateWindowFrame, epubSeekFrame]) - if (frame) cancelAnimationFrame(frame); - saveTimer = - themeAnimationTimer = - panelAnimationTimer = - markerFrame = - foliateScrollFrame = - foliateWindowFrame = - epubSeekFrame = - 0; - VoiceOfMLReaderStore.dispose?.(); -} -function fail(message, code = "READER_PARSE") { - if (readerLifecycle.disposed || readerAbortController.signal.aborted) return; - updateDocumentState({ restorationReady: false }); - readerRuntime.fail(code); - stopReaderWork(); - readerRequestManager.dispose(); - formatAdapters.dispose(); - window.__VOICE_PDF_PRELOAD__?.dispose(); - content.dataset.errorCode = code; - loadingStatus.hidden = true; - markReaderContentReady(); - const visibleMessage = `${message} [${readerLifecycle.stage}]`; - content.innerHTML = `
`; - content.querySelector(".reader-error").textContent = visibleMessage; - if (["READER_ENGINE_NETWORK", "READER_RESTORE"].includes(code)) { - const retry = document.createElement("button"); - retry.type = "button"; - retry.className = "text-button"; - retry.id = "reader-engine-retry"; - retry.textContent = "重试加载"; - // Reload clears the browser's failed module map and preserves this book URL. - retry.addEventListener("click", () => location.reload()); - content.querySelector(".reader-error").append(document.createElement("br"), retry); - } - status.textContent = "无法打开"; -} - -async function appendShellsBatched(createShell, total, batchSize) { - for (let start = 2; start <= total; start += batchSize) { - assertReaderActive(); - const fragment = document.createDocumentFragment(); - for (let page = start; page < Math.min(start + batchSize, total + 1); page++) - fragment.appendChild(createShell(page)); - content.appendChild(fragment); - await waitForReader(); - } -} -function pdfSelectionIntersects(element) { - const selection = document.getSelection(); - if (!selection?.rangeCount) return false; - if (element.contains(selection.anchorNode) || element.contains(selection.focusNode)) return true; - return !selection.isCollapsed && selection.getRangeAt(0).intersectsNode(element); -} -function correctPdfCrossPageSelection(x, y, start = null) { - const selection = document.getSelection(); - if (!selection?.rangeCount) return; - const anchorNode = start?.anchorNode || selection.anchorNode; - const anchorOffset = start?.anchorNode ? start.anchorOffset : selection.anchorOffset; - if (!start?.anchorNode && selection.isCollapsed) return; - const origin = anchorNode?.parentElement?.closest(".reader-page"); - let target = document.elementFromPoint(x, y)?.closest(".reader-page"); - const caret = start?.anchorNode && independentTextCaret(document, x, y); - if (caret && origin?.contains(anchorNode) && content.contains(caret.node)) { - selection.setBaseAndExtent(anchorNode, anchorOffset, caret.node, caret.offset); - return; - } - if (!target) { - let distance = Infinity; - for (const shell of content.querySelectorAll(".reader-page")) { - const rect = shell.getBoundingClientRect(); - const gap = Math.max(rect.top - y, y - rect.bottom, 0); - if (gap < distance) { distance = gap; target = shell; } - } - } - if (!origin || !target || origin === target || !content.contains(origin) || !content.contains(target)) return; - const runs = [...target.querySelectorAll(".reader-pdf-text-run")].filter((run) => run.firstChild?.nodeType === Node.TEXT_NODE); - if (!runs.length) return; - let nearest = null, distance = Infinity; - for (const run of runs) { - const rect = run.getBoundingClientRect(); - const dx = Math.max(rect.left - x, x - rect.right, 0); - const dy = Math.max(rect.top - y, y - rect.bottom, 0); - const gap = Math.hypot(dx, dy); - if (gap < distance) { nearest = run; distance = gap; } - } - const node = nearest.firstChild, rect = nearest.getBoundingClientRect(); - let offset; - if (y < rect.top || y > rect.bottom) { - offset = y < rect.top ? 0 : node.length; - } else { - const range = document.createRange(); - const vertical = getComputedStyle(nearest).writingMode.startsWith("vertical"); - const position = vertical ? y : x; - let low = 0, high = node.length; - while (low < high) { - const mid = Math.floor((low + high) / 2); - range.setStart(node, mid); - range.setEnd(node, mid + 1); - const glyph = range.getBoundingClientRect(); - const center = vertical ? (glyph.top + glyph.bottom) / 2 : (glyph.left + glyph.right) / 2; - if (center < position) low = mid + 1; - else high = mid; - } - offset = low; - } - selection.setBaseAndExtent(anchorNode, anchorOffset, node, offset); -} -function createPdfShellWindow(total, firstShell, createShell, unobserve, defaultRatio) { - if (total <= 256) return null; - const chunkSize = 32, chunks = [], ratios = new Map(); - let frame = 0, disposed = false; - const readWidth = () => { - const style = getComputedStyle(content); - return (content.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)) * documentState.zoom; - }; - const chunkHeight = (chunk, width) => { - let height = 0; - for (let page = chunk.start; page <= chunk.end; page++) - height += Math.max(160, width * (ratios.get(page) || defaultRatio)) + 18; - return height; - }; - const fragment = document.createDocumentFragment(); - const initialWidth = readWidth(); - for (let start = 1; start <= total; start += chunkSize) { - const element = document.createElement("div"); - element.className = "reader-pdf-chunk"; - element.setAttribute("aria-hidden", "true"); - const chunk = { element, start, end: Math.min(total, start + chunkSize - 1), mounted: false }; - chunks.push(chunk); - element.style.height = `${chunkHeight(chunk, initialWidth)}px`; - fragment.appendChild(element); - } - content.appendChild(fragment); - function mount(chunk) { - if (chunk.mounted) return; - const pages = document.createDocumentFragment(); - for (let page = chunk.start; page <= chunk.end; page++) { - const shell = page === 1 && firstShell.isConnected ? firstShell : createShell(page); - if (ratios.has(page)) shell.style.aspectRatio = `1 / ${ratios.get(page)}`; - pages.appendChild(shell); - } - chunk.element.style.height = ""; - chunk.element.removeAttribute("aria-hidden"); - chunk.element.appendChild(pages); - chunk.mounted = true; - } - function canUnmount(chunk) { - return chunk.mounted && !chunk.element.contains(document.activeElement) && - !pdfSelectionIntersects(chunk.element); - } - function measureChunk(chunk) { - for (const shell of chunk.element.children) { - const bounds = shell.getBoundingClientRect(); - if (bounds.width && bounds.height) ratios.set(Number(shell.dataset.page), bounds.height / bounds.width); - } - } - function unmount(chunk, width) { - if (!chunk.mounted || chunk.element.contains(document.activeElement) || - pdfSelectionIntersects(chunk.element)) return; - for (const shell of chunk.element.children) { - const page = Number(shell.dataset.page); - shell._textEpoch = (shell._textEpoch || 0) + 1; - shell._renderCancel?.(); - shell._hybridCancel?.(); - shell._v3PreviewRelease?.(); - unobserve(shell); - pdfManifestShells[page] = undefined; - } - chunk.element.replaceChildren(); - chunk.element.style.height = `${chunkHeight(chunk, width)}px`; - chunk.element.setAttribute("aria-hidden", "true"); - chunk.mounted = false; - } - function show(index) { - if (disposed) return; - const anchor = pageAtMarker(); - const before = isPdfPageVisible(anchor) ? anchor.getBoundingClientRect().top : undefined; - const retiring = chunks.filter((chunk, i) => Math.abs(i - index) > 2 && canUnmount(chunk)); - const width = retiring.length ? readWidth() : 0; - // Capture all retiring page geometry before mounting or removing any chunks. - for (const chunk of retiring) measureChunk(chunk); - for (let i = Math.max(0, index - 2); i <= Math.min(chunks.length - 1, index + 2); i++) - mount(chunks[i]); - for (const chunk of retiring) unmount(chunk, width); - if (anchor?.isConnected && before !== undefined) - viewport.scrollTop += anchor.getBoundingClientRect().top - before; - } - function sync() { - frame = 0; - if (disposed) return; - const marker = viewport.scrollTop + viewport.clientHeight / 2; - const index = chunks.findIndex((chunk) => chunk.element.offsetTop + chunk.element.offsetHeight > marker); - show(index < 0 ? chunks.length - 1 : index); - } - mount(chunks[0]); - const resizeObserver = new ResizeObserver(() => resize()); - resizeObserver.observe(viewport); - function resize() { - if (disposed) return; - const anchor = pageAtMarker(); - const before = isPdfPageVisible(anchor) ? anchor.getBoundingClientRect().top : undefined; - const width = readWidth(); - for (const chunk of chunks) if (!chunk.mounted) chunk.element.style.height = `${chunkHeight(chunk, width)}px`; - if (anchor?.isConnected && before !== undefined) - viewport.scrollTop += anchor.getBoundingClientRect().top - before; - schedule(); - } - function schedule() { - if (!disposed && !frame) frame = requestAnimationFrame(sync); - } - const window = { - ensure(page) { - const index = Math.floor((page - 1) / chunkSize); - show(index); - return chunks[index].element.querySelector(`.reader-page[data-page="${page}"]`); - }, - remember(shell) { - if (!shell.isConnected) return; - const bounds = shell.getBoundingClientRect(); - if (bounds.width && bounds.height) ratios.set(Number(shell.dataset.page), bounds.height / bounds.width); - }, - schedule, - resize, - dispose() { - disposed = true; - if (frame) cancelAnimationFrame(frame); - resizeObserver.disconnect(); - chunks.length = 0; - ratios.clear(); - pdfShellWindow = null; - } - }; - trackReaderResource(() => window.dispose()); - return window; -} -async function renderPdfPages(prepared) { - const response = await prepared; - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const decoded = JSON.parse(await VoiceOfMLReaderSecurity.readText(response, VoiceOfMLReaderSecurity.LIMITS.manifestBytes)); - const manifest = decoded?.kind === "pdf-reading" - ? validateReadingManifest(decoded, VoiceOfMLReaderSecurity.LIMITS.pdfPages) - : VoiceOfMLReaderSecurity.validatePdfPageManifest(decoded); - if (manifest.kind === "pdf-reading") { - if (pdfManifestSource()?.root.split("/").slice(0, 3).join("/") !== - `objects/${manifest.source_sha256.slice(0, 2)}/${manifest.source_sha256}`) - throw new Error("PDF_V3_SOURCE_MISMATCH"); - pdfV3Repository = createPdfV3Repository(manifest.source_sha256, manifest.page_count); - pdfV3Map = validatePageMap(await pdfV3Repository.read(manifest.page_map), manifest); - pdfV3Manifest = manifest; - pdfOcrManifest = manifest.text_layer ? manifest : null; - ocrManifestUrl = manifest.text_layer ? sourceUrl : ""; - } - assertReaderActive(); - const source = pdfManifestSource(); - const totalPages = manifest.page_count; - if (!source) throw new Error("PDF_MANIFEST_INVALID"); - pdfPageManifest = { ...source, pageCount: totalPages }; - if (pdfV3Manifest) pdfPageManifest.pageUrl = () => ""; - pdfHybridUrl = pdfPrimarySource(); - if (pdfHybridUrl) { - pdfHybridDocumentPromise = loadPdfDocument(pdfHybridUrl).then((pdf) => { - assertReaderActive(); - if (pdf.numPages !== totalPages) throw new Error("PDF_PRIMARY_PAGE_COUNT_MISMATCH"); - pdfDocument = pdf; - clearPdfManifestPrefetches(); - syncCapabilityControls(); - return pdf; - }); - pdfHybridDocumentPromise.catch((error) => { - if (error?.name !== "AbortError" && !readerLifecycle.disposed) - console.warn("Primary PDF unavailable; page stream retained", error); - }); - } - updateDocumentState({ pageCount: totalPages }); - pageInput.max = String(totalPages); - document.querySelector("#page-total").textContent = `/ ${totalPages}`; - status.textContent = `${totalPages} 页`; - await getInitialReaderRestoration(); - const initialPage = initialReaderPage(totalPages); - if (ocrManifestUrl && !navigator.connection?.saveData) { - loadPdfOcrPage(initialPage).catch(() => {}); - } - await preloadPdfFirstPage(totalPages); - let initialRenderGate = true; - if (Array.isArray(manifest.toc) && manifest.toc.length) - setToc( - manifest.toc.map((item) => ({ - label: `${item.title} · 第 ${item.page} 页`, - depth: item.depth, - activate: (generation) => goToPage(item.page, generation) - })) - ); - const observer = new IntersectionObserver( - (items) => { - if (readerAbortController.signal.aborted) return; - for (const entry of items) if (entry.isIntersecting) { - entry.target.dataset.renderVisible = isPdfPageVisible(entry.target) ? "1" : "0"; - renderPdfInBackground(entry.target); - } - }, - { root: viewport, rootMargin: `${PDF_PREFETCH_MARGIN}px 0px` } - ); - const visibleObserver = new IntersectionObserver( - (items) => { - if (readerAbortController.signal.aborted) return; - for (const entry of items) { - entry.target.dataset.renderVisible = entry.isIntersecting ? "1" : "0"; - if (entry.isIntersecting) { - promotePdfRenderWaiter(entry.target); - renderPdfInBackground(entry.target, false, pdfPagePriority(entry.target)); - if (entry.target.dataset.renderState === "rendered") renderPdfOcrText(entry.target); - } - } - }, - { root: viewport, threshold: 0 } - ); - const observeShell = (shell) => { - if (initialRenderGate) { - shell._pdfObserversPending = true; - return; - } - observer.observe(shell); - visibleObserver.observe(shell); - }; - const releaseInitialRenderGate = () => { - initialRenderGate = false; - for (const shell of content.querySelectorAll(".reader-page")) { - if (!shell._pdfObserversPending) continue; - delete shell._pdfObserversPending; - observeShell(shell); - } - }; - trackReaderResource(() => { - observer.disconnect(); - visibleObserver.disconnect(); - }); - const createShell = (page) => { - const shell = document.createElement("section"); - shell.className = "reader-page"; - shell.dataset.page = String(page); - if (pdfHybridUrl) shell.dataset.pdfHybrid = "1"; - shell.style.aspectRatio = "1 / 1.414"; - if (pdfV3Map) { - const geometry = pdfV3Map.pages[page - 1]; - shell.style.aspectRatio = `${geometry.width} / ${geometry.height}`; - } - pdfManifestShells[page] = shell; - shell.tabIndex = 0; - shell.setAttribute("role", "region"); - shell.setAttribute("aria-label", `第 ${page} 页`); - const pageState = document.createElement("div"); - pageState.className = "reader-page-state"; - setPdfPageLoadingState(pageState, page); - pageState.hidden = true; - const textLayer = document.createElement("div"); - textLayer.className = "reader-pdf-text"; - textLayer.setAttribute("role", "document"); - textLayer.setAttribute("aria-label", `第 ${page} 页正文`); - shell.append(pageState, textLayer); - if ( - page === pdfFirstPagePreloadPage && - pdfFirstPagePreload && - (!pdfFirstPagePreload.complete || pdfFirstPagePreload.naturalWidth) - ) - shell.appendChild(pdfFirstPagePreload); - shell.addEventListener("focus", () => renderPdfInBackground(shell)); - observeShell(shell); - return shell; - }; - const firstShell = createShell(1); - content.appendChild(firstShell); - markReaderContentReady(); - pdfShellWindow = createPdfShellWindow(totalPages, firstShell, createShell, (shell) => { - observer.unobserve(shell); - visibleObserver.unobserve(shell); - }, 1.414); - pdfShellsReady = pdfShellWindow ? Promise.resolve() : appendShellsBatched(createShell, totalPages, 250); - pdfShellsReady.catch(() => {}); - try { - const targetShell = pdfShellWindow - ? pdfShellWindow.ensure(initialPage) - : (await pdfShellsReady, content.querySelector(`.reader-page[data-page="${initialPage}"]`)); - if (!targetShell) throw new Error("PDF initial page shell missing"); - viewport.scrollTop = readerPageScrollTop(targetShell); - await renderPdfManifestShell(targetShell, false, true); - markReaderContentReady(); - viewport.scrollTop = readerPageScrollTop(targetShell); - } finally { - releaseInitialRenderGate(); - } -} -async function renderImagePages(prepared) { - const response = await prepared; - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const manifest = JSON.parse( - await VoiceOfMLReaderSecurity.readText(response, VoiceOfMLReaderSecurity.LIMITS.manifestBytes) - ); - if (!manifest || manifest.version !== 1 || manifest.kind !== "image-page-stream" || - !Number.isInteger(manifest.page_count) || manifest.page_count < 1 || - !Array.isArray(manifest.pages) || manifest.pages.length !== manifest.page_count) - throw new Error("IMAGE_MANIFEST_INVALID"); - const normalized = { - version: 2, - kind: "pdf-pages", - page_count: manifest.page_count, - entries: manifest.pages.map((entry, index) => ({ - page: index + 1, - path: entry.path, - bytes: entry.bytes, - sha256: entry.sha256 - })) - }; - return renderPdfPages(Promise.resolve(new Response(JSON.stringify(normalized), { - headers: { "Content-Type": "application/json" } - }))); -} -async function renderPdf(prepared) { - const pdf = await prepared; - assertReaderActive(); - pdfDocument = pdf; - updateDocumentState({ pageCount: pdf.numPages }); - pageInput.max = String(documentState.pageCount); - document.querySelector("#page-total").textContent = `/ ${documentState.pageCount}`; - status.textContent = `${pdf.numPages} 页`; - await getInitialReaderRestoration(); - const initialPage = initialReaderPage(pdf.numPages); - let initialRenderGate = true; - const firstPage = await measurePdf(awaitReader(pdf.getPage(initialPage)), "page", initialPage); - assertReaderActive(); - const firstViewport = firstPage.getViewport({ scale: 1 }); - const observer = new IntersectionObserver( - (entries) => - entries.forEach((entry) => { - if (readerAbortController.signal.aborted) return; - entry.target.dataset.renderVisible = entry.isIntersecting && isPdfPageVisible(entry.target) ? "1" : "0"; - if (entry.isIntersecting) renderPdfInBackground(entry.target); - }), - { root: document.querySelector("#viewport"), rootMargin: `${PDF_PREFETCH_MARGIN}px 0px` } - ); - const visibleObserver = new IntersectionObserver( - (entries) => - entries.forEach((entry) => { - if (readerAbortController.signal.aborted) return; - entry.target.dataset.renderVisible = entry.isIntersecting ? "1" : "0"; - if (entry.isIntersecting) { - promotePdfRenderWaiter(entry.target); - if (pdfUserHasScrolled || entry.target.dataset.renderState !== "rendering") - renderPdfInBackground(entry.target, false, pdfPagePriority(entry.target)); - } - }), - { root: document.querySelector("#viewport"), threshold: 0 } - ); - const pageObserver = new IntersectionObserver(() => scheduleMarkerSync(), { - root: document.querySelector("#viewport"), - threshold: [0, 0.5, 1] - }); - const observeShell = (shell) => { - if (initialRenderGate) { - shell._pdfObserversPending = true; - return; - } - observer.observe(shell); - visibleObserver.observe(shell); - pageObserver.observe(shell); - }; - const releaseInitialRenderGate = () => { - initialRenderGate = false; - for (const shell of content.querySelectorAll(".reader-page")) { - if (!shell._pdfObserversPending) continue; - delete shell._pdfObserversPending; - observeShell(shell); - } - }; - trackReaderResource(() => { - observer.disconnect(); - visibleObserver.disconnect(); - pageObserver.disconnect(); - }); - const createShell = (page) => { - const shell = document.createElement("section"); - shell.className = "reader-page"; - shell.dataset.page = String(page); - shell.style.aspectRatio = `${firstViewport.width} / ${firstViewport.height}`; - shell.tabIndex = 0; - shell.setAttribute("role", "region"); - shell.setAttribute("aria-label", `第 ${page} 页`); - const canvas = document.createElement("canvas"); - canvas.setAttribute("aria-hidden", "true"); - const textLayer = document.createElement("div"); - textLayer.className = "reader-pdf-text"; - textLayer.setAttribute("role", "document"); - textLayer.setAttribute("aria-label", `第 ${page} 页正文`); - shell.append(canvas, textLayer); - const pageState = document.createElement("div"); - pageState.className = "reader-page-state"; - setPdfPageLoadingState(pageState, page); - shell.appendChild(pageState); - shell.addEventListener("focus", () => renderPdfInBackground(shell)); - observeShell(shell); - return shell; - }; - const firstShell = createShell(1); - content.appendChild(firstShell); - pdfShellWindow = createPdfShellWindow(pdf.numPages, firstShell, createShell, (shell) => { - observer.unobserve(shell); - visibleObserver.unobserve(shell); - pageObserver.unobserve(shell); - }, firstViewport.height / firstViewport.width); - pdfShellsReady = pdfShellWindow ? Promise.resolve() : appendShellsBatched(createShell, pdf.numPages, 24); - try { - const targetShell = pdfShellWindow - ? pdfShellWindow.ensure(initialPage) - : (await pdfShellsReady, content.querySelector(`.reader-page[data-page="${initialPage}"]`)); - if (!targetShell) throw new Error("PDF initial page shell missing"); - markReaderContentReady(); - viewport.scrollTop = readerPageScrollTop(targetShell); - await renderPdfShell(targetShell, false, true); - markReaderContentReady(); - viewport.scrollTop = readerPageScrollTop(targetShell); - } finally { - releaseInitialRenderGate(); - } - const outlineReady = (async () => { - if (typeof pdf.getOutline !== "function") return; - try { - const outline = await awaitReader(pdf.getOutline()), - entries = []; - const cleanLabel = (value) => - String(value || "未命名章节") - .replace(/[\u2610-\u2612\u25a1]\s*[xX×]?\s*$/u, "") - .trim() || "未命名章节"; - const append = async (items, depth = 0) => { - for (const item of items || []) { - assertReaderActive(); - let destination = item.dest, - page = 0; - if (typeof destination === "string") destination = await pdf.getDestination(destination); - if (destination && destination[0]) { - try { - page = (await pdf.getPageIndex(destination[0])) + 1; - } catch (_) {} - } - entries.push({ - label: page ? `${cleanLabel(item.title)} · 第 ${page} 页` : cleanLabel(item.title), - depth, - activate: (generation) => (page ? goToPage(page, generation) : false) - }); - await append(item.items, depth + 1); - } - }; - await append(outline); - assertReaderActive(); - if (entries.length) setToc(entries); - } catch (error) { - if (!readerAbortController.signal.aborted) - console.warn("PDF outline could not be loaded", error); - } - })(); - outlineReady.catch(() => {}); - await pdfShellsReady; - assertReaderActive(); -} - -function setPdfPageLoadingState(pageState, page) { - pageState.dataset.state = "loading"; - pageState.setAttribute("role", "status"); - pageState.setAttribute("aria-label", `正在加载第 ${page} 页`); - pageState.replaceChildren(); - pageState.hidden = false; -} -function isPdfPageVisible(shell) { - const pageRect = shell?.getBoundingClientRect?.(); - const viewportRect = viewport?.getBoundingClientRect?.(); - return !!pageRect && !!viewportRect && pageRect.bottom > viewportRect.top && pageRect.top < viewportRect.bottom; -} -function pdfPagePriority(shell) { - const pageRect = shell?.getBoundingClientRect?.(); - const viewportRect = viewport?.getBoundingClientRect?.(); - if (!pageRect || !viewportRect) return 0; - if (pageRect.top >= viewportRect.top && pageRect.bottom <= viewportRect.bottom) return 3; - if (pageRect.bottom > viewportRect.top && pageRect.top < viewportRect.bottom) return 2; - return pageRect.bottom > viewportRect.top - PDF_PREFETCH_MARGIN && - pageRect.top < viewportRect.bottom + PDF_PREFETCH_MARGIN ? 1 : 0; -} -function normalizePdfPriority(priority) { - return priority === true ? 3 : Math.max(0, Math.min(3, Number(priority) || 0)); -} -function renderPdfInBackground(shell, force = false, priority = pdfPagePriority(shell)) { - priority = normalizePdfPriority(priority); - if (readerAbortController.signal.aborted) return; - if (!force && shell.dataset.renderFailed === "1") return; - if (priority >= 2 && (force || shell.dataset.renderState !== "rendered") && - pdfActiveRenders >= (matchMedia("(max-width: 700px)").matches ? 1 : 2)) - cancelSpeculativePdfRenders(shell); - if (priority) { - shell.dataset.renderPriority = "1"; - promotePdfRenderWaiter(shell); - if (["pdf-pages", "image-pages"].includes(capability.mode) && pdfPageManifest) - prioritizePdfManifestPrefetch(pdfPageManifest.pageUrl(Number(shell.dataset.page))); - } - if (shell._backgroundRender) { - if (priority >= 2 && shell._pdfRenderCancelled) shell._resumePdfDemand = true; - if (priority && ["pdf-pages", "image-pages"].includes(capability.mode)) { - const image = shell.querySelector("img"); - if (image) image.fetchPriority = priority >= 3 ? "high" : "auto"; - } - if (force) { - shell.dataset.pendingRerender = "1"; - if (pdfHybridUrl) shell.dataset.pendingHybridRerender = "1"; - } - return shell._backgroundRender; - } - const task = - ["pdf-pages", "image-pages"].includes(capability.mode) - ? renderPdfManifestShell(shell, force, priority) - : renderPdfShell(shell, force, priority); - shell._backgroundRender = task; - task - .then(() => { delete shell.dataset.renderRetries; delete shell.dataset.renderFailed; }) - .catch((error) => { - if (readerAbortController.signal.aborted || error?.name === "AbortError") return; - console.warn(`PDF page ${shell.dataset.page} render failed`, error); - const retries = Number(shell.dataset.renderRetries || 0); - const pageState = shell.querySelector(".reader-page-state"); - if (pageState && !shell.querySelector("canvas.ready, img.ready")) { - pageState.dataset.state = "error"; - pageState.removeAttribute("aria-label"); - pageState.hidden = false; - pageState.textContent = retries < 3 ? "页面加载失败,正在重试..." : "页面加载失败"; - } - if (retries < 3) { - shell.dataset.renderRetries = String(retries + 1); - waitForReader(400 * (retries + 1)) - .then(() => { - if (shell.isConnected) renderPdfInBackground(shell); - }) - .catch(() => {}); - } else { - shell.dataset.renderFailed = "1"; - if (pageState && !pageState.hidden) { - const retry = document.createElement("button"); - retry.type = "button"; - retry.textContent = "重试"; - retry.title = "重试加载此页"; - retry.addEventListener("click", () => { - delete shell.dataset.renderFailed; - delete shell.dataset.renderRetries; - setPdfPageLoadingState(pageState, shell.dataset.page); - renderPdfInBackground(shell, true, 3); - }, {once:true}); - pageState.append(retry); - } - } - }) - .finally(() => { - if (shell._backgroundRender === task) delete shell._backgroundRender; - delete shell._pdfRenderCancelled; - if (shell._resumePdfDemand) { - delete shell._resumePdfDemand; - if (!readerAbortController.signal.aborted && shell.isConnected && isPdfPageVisible(shell)) - queueMicrotask(() => renderPdfInBackground(shell)); - } - }); - return task; -} -function pdfPrimarySource() { - if (capability.mode !== "pdf-pages") return ""; - if (pdfV3Manifest) return v3ResourceUrl(pdfV3Manifest.primary.resource); - const candidate = normalizeSourceUrl(params.get("pdf_document") || resolvedReaderData?.pdf_document || - (resolvedReaderData?.original_extension === "pdf" || /\.pdf(?:$|[?#])/i.test(downloadUrl) - ? downloadUrl : "")); - try { - const parsed = new URL(candidate, location.href); - const path = parsed.searchParams.get("path") || parsed.pathname; - return validSource(parsed.href) && /\.pdf$/i.test(path) ? parsed.href : ""; - } catch (_) { return ""; } -} -function renderPdfManifestShell(shell, force = false, priority = false) { - if (!pdfHybridUrl) return renderPdfImageShell(shell, force, priority); - if (!force && shell.querySelector("canvas.ready")) return Promise.resolve(); - if (shell._hybridRenderPromise) { - if (force) shell.dataset.pendingHybridRerender = "1"; - return shell._hybridReadyPromise; - } - const controller = new AbortController(); - shell._hybridCancel = () => { controller.abort(); shell._renderCancel?.(); }; - const pdfTask = renderHybridPdfCanvas(shell, controller.signal, priority); - const previewTask = (async () => { - if (pdfDocument || shell.dataset.pdfPresented === "1") await waitForReader(250); - if (controller.signal.aborted || !shell.isConnected) throw readerAbortError(); - if (shell.querySelector("canvas.ready") || shell.dataset.pdfPresented === "1") return pdfTask; - if (!normalizePdfPriority(priority) && pdfDocument) return pdfTask; - const preview = await renderPdfImageShell(shell, false, priority); - if (controller.signal.aborted) throw readerAbortError(); - return preview; - })(); - const ready = Promise.any([pdfTask, previewTask]).catch((error) => { - if (controller.signal.aborted || readerAbortController.signal.aborted) throw readerAbortError(); - throw error.errors?.[1] || error.errors?.[0] || error; - }); - shell._hybridReadyPromise = ready; - const complete = Promise.allSettled([pdfTask, previewTask]).finally(() => { - if (shell._hybridRenderPromise === complete) { - delete shell._hybridRenderPromise; - delete shell._hybridReadyPromise; - delete shell._hybridCancel; - if (shell.dataset.pendingHybridRerender && shell.isConnected && !readerLifecycle.disposed) { - delete shell.dataset.pendingHybridRerender; - renderPdfInBackground(shell, true); - } - } - }); - shell._hybridRenderPromise = complete; - return ready; -} -async function renderHybridPdfCanvas(shell, signal, priority) { - let acquired = false, canvas = null, rendering = null; - const untrack = trackReaderResource(() => { rendering?.cancel?.(); shell._hybridCancel?.(); }); - const cancel = () => rendering?.cancel?.(); - signal.addEventListener("abort", cancel, { once: true }); - try { - const pdf = await awaitReader(pdfHybridDocumentPromise, signal); - do { - const generation = readerRuntime.currentGeneration("pdf"); - if (signal.aborted || !shell.isConnected) throw readerAbortError(); - const page = await measurePdf(awaitReader(pdf.getPage(Number(shell.dataset.page)), signal), "page", Number(shell.dataset.page)); - if (signal.aborted || !shell.isConnected) throw readerAbortError(); - if (!acquired) { - await measurePdf(acquirePdfRenderSlot(priority, shell, signal), "queue", Number(shell.dataset.page)); - acquired = true; - shell._pdfRenderOwnsSlot = true; - } - const base = page.getViewport({ scale: 1 }); - if (pdfV3Map) { - const geometry = pdfV3Map.pages[Number(shell.dataset.page) - 1]; - if (Math.abs((base.width / base.height) / (geometry.width / geometry.height) - 1) > .01) - throw new Error("PDF_V3_DOCUMENT_GEOMETRY_MISMATCH"); - } - const view = page.getViewport({ scale: Math.min(3, shell.clientWidth / base.width) }); - const outputScale = Math.min(2, Math.max(1, Number(window.devicePixelRatio) || 1)); - canvas = document.createElement("canvas"); - canvas.setAttribute("aria-hidden", "true"); - canvas.width = Math.ceil(view.width * outputScale); - canvas.height = Math.ceil(view.height * outputScale); - rendering = page.render({ canvasContext: canvas.getContext("2d"), viewport: view, - transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0] }); - await measurePdf(awaitReader(rendering.promise, signal), "draw", Number(shell.dataset.page)); - rendering = null; - while (!pdfV3Manifest && pdfSelectionIntersects(shell) && !signal.aborted) await waitForReader(100); - if (signal.aborted || !shell.isConnected) throw readerAbortError(); - if (!isReaderGenerationCurrent("pdf", generation)) { - canvas.width = canvas.height = 0; - continue; - } - const anchor = content.querySelector(`.reader-page[data-page="${documentState.page}"]`); - const oldTop = anchor?.getBoundingClientRect().top; - const oldHeight = anchor?.getBoundingClientRect().height || 1; - const fraction = anchor ? Math.max(0, (viewport.getBoundingClientRect().top - oldTop) / oldHeight) : 0; - const previous = shell.querySelector("canvas"); - canvas.classList.add("ready"); - if (previous) { previous.replaceWith(canvas); previous.width = previous.height = 0; } - else shell.prepend(canvas); - shell.style.aspectRatio = `${base.width} / ${base.height}`; - shell._renderCancel?.(); - shell.querySelector("img")?.remove(); - shell._v3PreviewRelease?.(); - const pageState = shell.querySelector(".reader-page-state"); - if (pageState) pageState.hidden = true; - shell.dataset.pdfPresented = "1"; - delete shell.dataset.pendingHybridRerender; - shell.dataset.renderState = "rendered"; - delete shell.dataset.renderRetries; - delete shell.dataset.renderFailed; - shell.dataset.renderUsedAt = String(Date.now()); - if (anchor) { - const current = anchor.getBoundingClientRect(); - viewport.scrollTop += current.top - oldTop + fraction * (current.height - oldHeight); - } - pdfShellWindow?.remember(shell); - trimPdfCanvases(shell); - (ocrManifestUrl ? renderPdfOcrText(shell) : renderPdfText(page, shell, signal)).catch(() => {}); - scheduleMarkerSync(); - canvas = null; - return; - } while (true); - } finally { - rendering?.cancel?.(); - if (canvas) canvas.width = canvas.height = 0; - if (acquired) { delete shell._pdfRenderOwnsSlot; releasePdfRenderSlot(); } - signal.removeEventListener("abort", cancel); - untrack(); - } -} -function renderPdfImageShell(shell, force = false, priority = false) { - if (!shell) return Promise.reject(new Error("PDF page shell missing")); - if (shell._renderPromise) return shell._renderPromise; - if (!force && shell.dataset.renderState === "rendered") return Promise.resolve(); - shell.dataset.renderState = "rendering"; - const renderController = new AbortController(); - const hybridPreview = !!pdfHybridUrl; - shell._renderCancel = () => renderController.abort(); - shell._renderStarted = false; - priority = normalizePdfPriority(priority || pdfPagePriority(shell)); - shell._renderActivePriority = String(priority); - const task = (async () => { - let acquired = false; - try { - if (hybridPreview) { - while (pdfHybridPreviewLoads >= 2) { - await waitForReader(25); - if (renderController.signal.aborted) throw readerAbortError(); - } - if (renderController.signal.aborted) throw readerAbortError(); - pdfHybridPreviewLoads++; - } else await acquirePdfRenderSlot(priority, shell, renderController.signal); - acquired = true; - shell._renderStarted = true; - assertReaderActive(); - const entry = pdfPageEntry(Number(shell.dataset.page)); - if (!entry) throw new Error("PDF page entry missing"); - let target = pdfPageManifest.pageUrl(entry.page); - if (pdfV3Manifest) { - const preview = await pdfV3Repository.preview(pdfV3Manifest, pdfV3Map, entry.page); - shell._v3PreviewEntry = preview.entry; - assertReaderActive(); - if (renderController.signal.aborted) throw readerAbortError(); - shell._v3PreviewRelease?.(); - target = URL.createObjectURL(preview.blob); - const release = () => { URL.revokeObjectURL(target); pdfV3BlobReleases.delete(release); delete shell._v3PreviewRelease; }; - shell._v3PreviewRelease = release; - pdfV3BlobReleases.add(release); - } - let loadText = null; - let image = shell.querySelector("img"); - const pageState = shell.querySelector(".reader-page-state"); - if (!image) { - image = takePdfManifestPrefetch(target) || new Image(); - image.alt = `第 ${entry.page} 页`; - image.decoding = "async"; - shell.appendChild(image); - } - image.fetchPriority = priority >= 3 ? "high" : priority >= 1 ? "auto" : "low"; - if (priority >= 2 && ocrManifestUrl && !navigator.connection?.saveData) - loadText = renderPdfOcrText(shell); - loadText?.catch(() => {}); - await loadReaderImage(image, target, renderController.signal); - assertReaderActive(); - if (shell.dataset.pdfPresented === "1") return; - if (pdfV3Manifest) { - const geometry = pdfV3Map.pages[entry.page - 1]; - if (image.naturalWidth !== shell._v3PreviewEntry.width || image.naturalHeight !== shell._v3PreviewEntry.height || - Math.abs((image.naturalWidth / image.naturalHeight) / (geometry.width / geometry.height) - 1) > .01) - throw new Error("PDF_V3_PREVIEW_GEOMETRY_MISMATCH"); - } - shell.style.aspectRatio = `${image.naturalWidth || 1} / ${image.naturalHeight || 1}`; - if (priority >= 2 && ocrManifestUrl && !navigator.connection?.saveData) - loadText ||= renderPdfOcrText(shell); - loadText?.catch(() => {}); - image.classList.add("ready"); - if (pageState) pageState.hidden = true; - shell.dataset.renderState = "rendered"; - shell.dataset.renderUsedAt = String(Date.now()); - pdfShellWindow?.remember(shell); - if (isPdfPageVisible(shell) || !pdfManifestPrefetchOrigin) schedulePdfManifestPrefetch(entry.page); - else if (!pdfManifestPrefetchActive.size && !pdfManifestPrefetchTimer) queuePdfManifestPrefetch(); - trimPdfManifestImages(shell); - scheduleMarkerSync(); - } catch (error) { - shell.querySelector("img")?.remove(); - shell._v3PreviewRelease?.(); - const pageState = shell.querySelector(".reader-page-state"); - if (pageState && shell.dataset.pdfPresented !== "1") { - pageState.dataset.state = "error"; - pageState.removeAttribute("aria-label"); - pageState.hidden = false; - pageState.textContent = "页面加载失败,正在重试…"; - } - if (shell.dataset.pdfPresented !== "1") shell.dataset.renderState = "idle"; - throw error; - } finally { - if (acquired) { - if (hybridPreview) pdfHybridPreviewLoads--; - else releasePdfRenderSlot(); - } - } - })().finally(() => { - if (shell._renderPromise === task) delete shell._renderPromise; - if (shell._renderCancel) delete shell._renderCancel; - delete shell._renderStarted; - delete shell._renderActivePriority; - }); - shell._renderPromise = task; - return task; -} -function cancelSpeculativePdfRenders(protectedShell) { - for (const shell of content.querySelectorAll('.reader-page')) { - if (!shell._hybridRenderPromise && shell.dataset.renderState !== "rendering") continue; - if (!shell._pdfRenderOwnsSlot && !shell._renderStarted) continue; - if ( - shell === protectedShell || - isPdfPageVisible(shell) || pdfSelectionIntersects(shell) - ) - continue; - shell._pdfRenderCancelled = true; - shell._renderCancel?.(); - shell._hybridCancel?.(); - } -} -function renderPdfShell(shell, force = false, priority = false) { - if (shell._renderPromise) { - if (force) shell.dataset.pendingRerender = "1"; - return shell._renderPromise; - } - if (!force && shell.dataset.renderState === "rendered") return Promise.resolve(); - shell.dataset.renderState = "rendering"; - const loadingState = shell.querySelector(".reader-page-state"); - if (loadingState && !shell.querySelector("canvas.ready, img.ready")) - setPdfPageLoadingState(loadingState, shell.dataset.page); - const renderController = new AbortController(); - priority = normalizePdfPriority(priority || pdfPagePriority(shell)); - let activeRendering = null; - shell._renderCancel = () => { - renderController.abort(); - activeRendering?.cancel?.(); - }; - const task = (async () => { - let acquired = false; - try { - await measurePdf(acquirePdfRenderSlot(priority, shell, renderController.signal), "queue", Number(shell.dataset.page)); - acquired = true; - shell._pdfRenderOwnsSlot = true; - assertReaderActive(); - if (renderController.signal.aborted) throw readerAbortError(); - const pdf = pdfDocument; - if (!pdf) throw readerAbortError(); - do { - delete shell.dataset.pendingRerender; - const generation = readerRuntime.currentGeneration("pdf"), - pageNumber = Number(shell.dataset.page), - page = await measurePdf(awaitReader(pdf.getPage(pageNumber), renderController.signal), "page", pageNumber); - assertReaderActive(); - if (renderController.signal.aborted) throw readerAbortError(); - const base = page.getViewport({ scale: 1 }), - scale = Math.min(3, shell.clientWidth / base.width), - viewport = page.getViewport({ scale }), - outputScale = Math.min(2, Math.max(1, Number(window.devicePixelRatio) || 1)); - const canvas = shell.querySelector("canvas"); - canvas.width = Math.ceil(viewport.width * outputScale); - canvas.height = Math.ceil(viewport.height * outputScale); - canvas.style.width = "100%"; - canvas.style.height = "auto"; - shell.style.aspectRatio = `${viewport.width} / ${viewport.height}`; - const rendering = page.render({ - canvasContext: canvas.getContext("2d"), - viewport, - transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0] - }); - activeRendering = rendering; - const untrack = trackReaderResource(() => rendering.cancel?.()); - const textTask = renderPdfText(page, shell, renderController.signal); - textTask.catch(() => {}); - try { - // The canvas is the first visible result. Text extraction can need a - // separate range and must not delay the visual first page. - await measurePdf(awaitReader(rendering.promise, renderController.signal), "draw", Number(shell.dataset.page)); - } finally { - untrack(); - activeRendering = null; - } - assertReaderActive(); - if (renderController.signal.aborted) throw readerAbortError(); - if (!isReaderGenerationCurrent("pdf", generation)) shell.dataset.pendingRerender = "1"; - } while (shell.dataset.pendingRerender); - shell.querySelector("canvas").classList.add("ready"); - const pageState = shell.querySelector(".reader-page-state"); - if (pageState) pageState.hidden = true; - shell.dataset.renderState = "rendered"; - shell.dataset.renderUsedAt = String(Date.now()); - pdfShellWindow?.remember(shell); - trimPdfCanvases(shell); - delete shell.dataset.renderRetries; - delete shell.dataset.renderFailed; - } catch (error) { - shell.dataset.renderState = "idle"; - throw error; - } finally { - if (acquired) { delete shell._pdfRenderOwnsSlot; releasePdfRenderSlot(); } - } - })().finally(() => { - if (shell._renderPromise === task) delete shell._renderPromise; - if (shell._renderCancel) delete shell._renderCancel; - }); - shell._renderPromise = task; - return task; -} - -async function renderPdfText(page, shell, signal = null) { - if (ocrManifestUrl && pdfV3Manifest) return renderPdfOcrText(shell); - if (shell.dataset.textReady === "1") return; - const layer = shell.querySelector(".reader-pdf-text"); - if (!layer || typeof page.getTextContent !== "function") return; - const epoch = shell._textEpoch || 0; - try { - const pageNumber = Number(shell.dataset.page); - const text = await loadPdfTextContent(pageNumber, page); - assertReaderActive(); - if (signal?.aborted) return; - cachePdfTextContent(pageNumber, text); - if (epoch !== (shell._textEpoch || 0) || !shell.isConnected) return; - const pdfViewport = page.getViewport({ scale: 1 }); - shell._bookmarkTextItems = text.items - .filter((item) => item.str && item.str.trim()) - .map((item) => { - const point = - item.transform && pdfViewport.convertToViewportPoint - ? pdfViewport.convertToViewportPoint(item.transform[4], item.transform[5]) - : null; - return { - text: item.str, - y: point ? Math.max(0, Math.min(1, point[1] / Math.max(1, pdfViewport.height))) : 0 - }; - }); - populatePdfTextLayer(layer, text.items, pdfViewport, text.styles); - shell.dataset.textReady = "1"; - highlightPdfText(shell); - } catch (error) { - if (!readerAbortController.signal.aborted && !signal?.aborted) - console.warn(`PDF page ${shell.dataset.page} text extraction failed`, error); - } -} -function highlightPdfText(shell, query = searchState.query) { - if (!shell || readerAbortController.signal.aborted || !query) return; - const layer = shell.querySelector(".reader-pdf-text"); - if (!layer || layer.querySelector("mark")) return; - const walker = document.createTreeWalker(layer, NodeFilter.SHOW_TEXT), runs = []; - let raw = ""; - while (walker.nextNode()) { - const node = walker.currentNode, start = raw.length; - raw += node.data; - runs.push({ node, start, end: raw.length }); - } - const mapping = pdfSearchText(raw); - const hits = []; - for (const hit of mapping.text.matchAll(new RegExp(fullSearchEscape(normalizePdfSearchText(query)), "giu"))) { - hits.push(hit); - if (hits.length === 100) break; - } - // Bound highlights in the displayed page, without limiting searchable results. - for (const hit of hits.reverse()) { - const start = pdfSourceOffset(mapping, hit.index), end = pdfSourceOffset(mapping, hit.index + hit[0].length - 1) + 1; - highlightTextParts(runs.filter((run) => run.end > start && run.start < end).map((run) => ({ - node: run.node, start: Math.max(start, run.start) - run.start, end: Math.min(end, run.end) - run.start - }))); - } -} - -function promotePdfRenderWaiter(shell) { - for (const waiter of pdfRenderWaiters) { - if (waiter.shell === shell) waiter.priority = Math.max(waiter.priority, pdfPagePriority(shell)); - } -} -function acquirePdfRenderSlot(priority = false, shell = null, signal = null) { - if (readerAbortController.signal.aborted) return Promise.reject(readerAbortError()); - if (signal?.aborted) return Promise.reject(readerAbortError()); - const limit = matchMedia("(max-width: 700px)").matches ? 1 : 2; - if (pdfActiveRenders < limit) { - pdfActiveRenders++; - return Promise.resolve(); - } - return new Promise((resolve, reject) => { - let settled = false; - const remove = () => { - const index = pdfRenderWaiters.indexOf(waiter); - if (index >= 0) pdfRenderWaiters.splice(index, 1); - signal?.removeEventListener("abort", abort); - }; - const abort = () => { - if (settled) return; - settled = true; - remove(); - reject(readerAbortError()); - }; - priority = normalizePdfPriority(priority); - const waiter = { - shell, - priority, - resolve: () => { - if (settled) return; - settled = true; - signal?.removeEventListener("abort", abort); - pdfActiveRenders++; - resolve(); - }, - reject: (error) => { - if (settled) return; - settled = true; - signal?.removeEventListener("abort", abort); - reject(error); - } - }; - signal?.addEventListener("abort", abort, { once: true }); - const index = pdfRenderWaiters.findIndex((item) => item.priority < priority); - if (index < 0) pdfRenderWaiters.push(waiter); - else pdfRenderWaiters.splice(index, 0, waiter); - }); -} -function releasePdfRenderSlot() { - pdfActiveRenders = Math.max(0, pdfActiveRenders - 1); - if (readerAbortController.signal.aborted) return; - let index = -1, best = -1, nearest = Infinity; - for (let i = 0; i < pdfRenderWaiters.length; i++) { - const waiter = pdfRenderWaiters[i], value = waiter.shell ? pdfPagePriority(waiter.shell) : waiter.priority; - const rect = waiter.shell?.getBoundingClientRect(), bounds = viewport.getBoundingClientRect(); - const distance = rect ? Math.max(bounds.top - rect.bottom, rect.top - bounds.bottom, 0) : Infinity; - if (value > best || (value === best && distance < nearest)) { - best = value; nearest = distance; index = i; - } - } - if (index < 0) index = 0; - const waiter = pdfRenderWaiters.splice(index, 1)[0]; - if (waiter) waiter.resolve(); -} -function acquirePdfTextSlot(priority = false, request = null) { - if (readerAbortController.signal.aborted) return Promise.reject(readerAbortError()); - return new Promise((resolve, reject) => { - pdfTextWaiters.push({ priority, request, resolve, reject }); - drainPdfTextWaiters(); - }); -} -function drainPdfTextWaiters() { - if (readerAbortController.signal.aborted) return; - const limit = matchMedia("(max-width: 700px)").matches ? 2 : 3; - for (let index = pdfTextWaiters.length - 1; index >= 0; index--) { - const request = pdfTextWaiters[index].request; - if (request && !request.demanded && !isReaderGenerationCurrent("search", request.generation)) { - if (pdfTextContentPromises.get(request.page)?.request === request) - pdfTextContentPromises.delete(request.page); - pdfTextWaiters.splice(index, 1)[0].reject(readerAbortError()); - } - } - while (pdfActiveTextLoads < limit) { - let index = pdfTextWaiters.findIndex(waiter => waiter.priority || waiter.request?.demanded); - if (index < 0) index = pdfTextWaiters.findIndex(waiter => - !waiter.request || pdfActiveSearchTextLoads < limit - 1); - if (index < 0) return; - const waiter = pdfTextWaiters.splice(index, 1)[0]; - const background = !!waiter.request && !waiter.request.demanded; - pdfActiveTextLoads++; - if (background) pdfActiveSearchTextLoads++; - waiter.resolve(background); - } -} -function releasePdfTextSlot(background = false) { - pdfActiveTextLoads = Math.max(0, pdfActiveTextLoads - 1); - if (background) pdfActiveSearchTextLoads = Math.max(0, pdfActiveSearchTextLoads - 1); - drainPdfTextWaiters(); -} -function trimPdfCanvases(protectedShell) { - trimPdfSurfaces(protectedShell); -} +readerBindings.bindPdfPointerSelection(); +let pdfSelectionBoundaryUpdate = false; +readerBindings.bindSelectionAndPageLifecycle(); -function rerenderVisiblePdfPages() { - nextReaderGeneration("pdf"); - for (const shell of content.querySelectorAll(".reader-page")) { - const rect = shell.getBoundingClientRect(); - if (rect.bottom >= -1200 && rect.top <= innerHeight + 1200) { - if (shell._renderPromise) shell.dataset.pendingRerender = "1"; - else renderPdfInBackground(shell, true); - } - } -} -async function renderText(markdown, prepared) { - let response = await prepared.response; - if (!response.ok) response = await fetchReaderResponse(); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - if (!markdown) await renderPlainText(response); - else { - const bytes = await readPreparedBytes(response, prepared.engines); - const text = new TextDecoder(detectTextEncoding(bytes, documentState.title)).decode(bytes); - const article = document.createElement("article"); - article.className = "reader-markdown"; - article.innerHTML = DOMPurify.sanitize(marked.parse(text), { USE_PROFILES: { html: true } }); - content.appendChild(article); - setToc(headingTocEntries(article)); - } - markReaderContentReady(); - status.textContent = "已加载"; -} // HTML documents retain an isolated, script-free scroll window. -const HTML_FRAME_STYLE = - ''; -const DISCOURSE_POST_SELECTOR = ".topic-post .cooked"; -const DISCOURSE_TITLE_SELECTOR = "#topic-title .fancy-title, .topic-link, title"; - -function sanitizeOfflineHtml(text) { - const clean = DOMPurify.sanitize(text, { - USE_PROFILES: { html: true }, - ADD_TAGS: ["style"], - FORBID_TAGS: ["base", "embed", "form", "iframe", "object", "script"], - FORBID_ATTR: ["action", "formaction", "srcdoc"], - ALLOWED_URI_REGEXP: /^data:image\/(?:gif|png|jpeg|webp|svg\+xml);/i - }); - const template = document.createElement("template"); - template.innerHTML = clean; - const stripUrls = (value) => value.replace(/@import[^;]+;|url\s*\([^)]*\)/gi, ""); - for (const style of template.content.querySelectorAll("style")) - style.textContent = stripUrls(style.textContent); - for (const element of template.content.querySelectorAll("[style]")) - element.setAttribute("style", stripUrls(element.getAttribute("style"))); - return template.innerHTML; -} -function buildDiscourseDocument(text) { - try { - const source = new DOMParser().parseFromString(text, "text/html"); - const posts = [...source.querySelectorAll(DISCOURSE_POST_SELECTOR)]; - if (!source.querySelector(".post-stream") || !posts.length) return null; - const article = source.createElement("article"); - article.className = "reader-forum-document"; - const titleNode = source.querySelector(DISCOURSE_TITLE_SELECTOR); - const title = titleNode?.textContent?.trim(); - if (title) { - const heading = source.createElement("h1"); - heading.textContent = title; - article.appendChild(heading); - } - posts.forEach((post, index) => { - const section = source.createElement("section"); - section.className = "reader-forum-post"; - section.dataset.post = String(index + 1); - section.innerHTML = post.innerHTML; - article.appendChild(section); - }); - return article.outerHTML; - } catch (_) { - return null; - } -} - -function prepareHtmlDocument(text) { - const discourse = buildDiscourseDocument(text); - const html = sanitizeOfflineHtml(discourse || text); - return { - html, - discourse: !!discourse, - spreadsheet: !discourse && html.includes("reader-spreadsheet-sheet") - }; -} - -function buildHtmlFrameSource(documentData) { - return documentData.html + HTML_FRAME_STYLE + - (documentData.discourse ? DISCOURSE_HTML_STYLE : "") + - (documentData.spreadsheet ? SPREADSHEET_HTML_STYLE : ""); -} - -const DISCOURSE_HTML_STYLE = ``; -const SPREADSHEET_HTML_STYLE = ``; - -async function waitForSpreadsheetImages(frame) { - if (frame.contentDocument) - await settleReaderImages(frame.contentDocument, 30000); -} - -async function renderHtml(prepared) { - const bytes = await readPreparedBytes(prepared.response, prepared.engine); - const text = new TextDecoder(detectHtmlEncoding(bytes, documentState.title)).decode(bytes); - assertReaderActive(); - const documentData = prepareHtmlDocument(text); - const frame = document.createElement("iframe"); - htmlFrame = frame; - frame.className = "html-frame"; - content.classList.toggle("reader-content--spreadsheet", documentData.spreadsheet); - if (documentData.spreadsheet) frame.classList.add("spreadsheet-html-frame"); - frame.title = `${documentState.title} 正文`; - frame.setAttribute("sandbox", "allow-same-origin"); - frame.setAttribute("referrerpolicy", "no-referrer"); - frame.style.colorScheme = "only light"; - const frameLoaded = new Promise((resolve, reject) => { - let untrack = () => {}, frameTimer = 0, deadline = 0, started = false; - const cleanup = () => { - clearTimeout(frameTimer); - clearTimeout(deadline); - frame.removeEventListener("load", loaded); - untrack(); - }; - const loaded = async () => { - if (started || frame.contentDocument?.URL !== "about:srcdoc" || frame.contentDocument.readyState === "loading") return; - started = true; - cleanup(); - try { - assertReaderActive(); - repairHtmlContrast(frame); - frame.contentDocument.documentElement.style.zoom = String(documentState.zoom); - frame.contentWindow.addEventListener("scroll", handleReaderPositionChange, { - passive: true - }); - frame.contentDocument.addEventListener("keydown", handleReaderKeydown); - installReaderTapSelectionClear(frame.contentDocument.body, frame.contentDocument); - for (const type of ["wheel", "touchstart", "pointerdown"]) - frame.contentDocument.addEventListener(type, beginReaderNavigation, { passive: true }); - setToc(headingTocEntries(frame.contentDocument)); - if (documentData.spreadsheet) await waitForSpreadsheetImages(frame); - assertReaderActive(); - resolve(); - } catch (error) { - reject(error); - } - }; - untrack = trackReaderResource(() => { - cleanup(); - reject(readerAbortError()); - }); - const poll = () => { - if (started) return; - loaded(); - if (!started) frameTimer = setTimeout(poll, 16); - }; - frameTimer = setTimeout(poll, 0); - deadline = setTimeout(() => { cleanup(); reject(new Error("HTML document initialization timeout")); }, 20000); - frame.addEventListener("load", loaded); - }); - frame.srcdoc = buildHtmlFrameSource(documentData); - content.appendChild(frame); - await frameLoaded; - markReaderContentReady(); - status.textContent = documentData.spreadsheet ? "表格" : "HTML"; -} - -function detectHtmlEncoding(bytes, hint = "") { - const probe = String.fromCharCode(...bytes.subarray(0, 8192)); - const match = probe.match(/charset\s*=\s*["']?\s*([a-z0-9._:-]+)/i); - if (match) { - const label = - { gb2312: "gb18030", "gb-2312": "gb18030", gbk: "gb18030", "x-gbk": "gb18030" }[ - match[1].toLowerCase() - ] || match[1]; - try { - new TextDecoder(label); - return label; - } catch (_) {} - } - return detectTextEncoding(bytes, hint); -} - -function repairHtmlContrast(frame) { - const doc = frame.contentDocument; - if (!doc || !doc.body) return; - const parseColor = (value) => { - const parts = String(value).match(/[\d.]+/g); - return parts && parts.length >= 3 - ? [ - Number(parts[0]), - Number(parts[1]), - Number(parts[2]), - parts[3] === undefined ? 1 : Number(parts[3]) - ] - : null; - }; - const luminance = (color) => { - const channels = color.slice(0, 3).map((value) => { - const normalized = value / 255; - return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; - }); - return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; - }; - const backgrounds = new Map(); - const background = (element, style) => { - const pending = []; - let light = 1; - for (let current = element; current; current = current.parentElement) { - if (backgrounds.has(current)) { - light = backgrounds.get(current); - break; - } - pending.push(current); - const color = parseColor((current === element ? style : - frame.contentWindow.getComputedStyle(current)).backgroundColor); - if (color && color[3] > 0.1) { - light = luminance(color); - break; - } - } - for (const current of pending) backgrounds.set(current, light); - return light; - }; - const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT); - const elements = new Set(); - while (walker.nextNode() && elements.size < 10000) - if (walker.currentNode.data.trim()) elements.add(walker.currentNode.parentElement); - for (const element of elements) { - const style = frame.contentWindow.getComputedStyle(element); - const foreground = parseColor(style.color); - const dark = background(element, style); - if (!foreground) continue; - const light = luminance(foreground); - const contrast = (Math.max(light, dark) + 0.05) / (Math.min(light, dark) + 0.05); - if (contrast < 3) { - element.style.setProperty("color", dark > 0.45 ? "#111" : "#f5f5f5", "important"); - // Inherited color and currentColor backgrounds can change after a repair. - backgrounds.clear(); - } - } -} -async function renderPlainText(response) { - assertReaderActive(); - const pre = document.createElement("div"); - pre.className = "reader-text"; - content.appendChild(pre); - let block = null, blockLength = 0; - const appendDecoded = async value => { - const text = value.replace(/\ufffd/g, ""); - let offset = 0, deadline = performance.now() + 8; - while (offset < text.length) { - if (!block) { - block = document.createElement("pre"); - block.className = "reader-text-block"; - pre.appendChild(block); - } - let end = Math.min(text.length, offset + 32768 - blockLength); - if (end < text.length) { - const newline = text.lastIndexOf("\n", end - 1); - if (newline >= offset) end = newline + 1; - const previous = text.charCodeAt(end - 1), next = text.charCodeAt(end); - if ((previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) || - (previous === 13 && next === 10)) end--; - } - if (end === offset) { block = null; blockLength = 0; continue; } - const tail = block.lastChild; - if (tail?.nodeType === Node.TEXT_NODE) tail.appendData(text.slice(offset, end)); - else block.appendChild(document.createTextNode(text.slice(offset, end))); - if (text.length) markReaderContentReady(); - blockLength += end - offset; - offset = end; - if (offset < text.length || blockLength >= 32768) { block = null; blockLength = 0; } - if (offset < text.length && performance.now() >= deadline) { - await waitForReader(); - assertReaderActive(); - deadline = performance.now() + 8; - } - } - }; - const limit = VoiceOfMLReaderSecurity.LIMITS.documentBytes; - VoiceOfMLReaderSecurity.assertResponseSize(response, limit); - if (!response.body?.getReader) { - const bytes = new Uint8Array(await response.arrayBuffer()); - if (bytes.byteLength > limit) { - const error = new Error("READER_RESOURCE_LIMIT"); - error.code = "READER_RESOURCE_LIMIT"; - throw error; - } - assertReaderActive(); - await appendDecoded(new TextDecoder(detectTextEncoding(bytes, documentState.title)).decode(bytes)); - return; - } - const reader = response.body.getReader(), - sampleChunks = [], - previewDecoder = new TextDecoder("utf-8"); - const untrack = trackReaderResource(() => { - reader.cancel(readerAbortError()).catch(() => {}); - }); - let length = 0, - sampleLength = 0, - displayedSampleSize = 0, - previewChunkIndex = 0, - previewChunkOffset = 0, - decoder = null, - asciiPreviewPossible = true, - asciiPrefixLength = 0; - const startDecoder = async () => { - const sample = new Uint8Array(sampleLength); - let offset = 0; - for (const chunk of sampleChunks) { - sample.set(chunk, offset); - offset += chunk.byteLength; - } - decoder = new TextDecoder(detectTextEncoding(sample, documentState.title)); - await appendDecoded(decoder.decode(sample.subarray(displayedSampleSize), { stream: true })); - sampleChunks.length = 0; - }; - try { - while (true) { - const { value, done } = await awaitReader(reader.read()); - assertReaderActive(); - if (done) break; - if (!value?.byteLength) continue; - length += value.byteLength; - if (length > limit) { - const error = new Error("READER_RESOURCE_LIMIT"); - error.code = "READER_RESOURCE_LIMIT"; - throw error; - } - if (!decoder) { - sampleChunks.push(value); - sampleLength += value.byteLength; - if (asciiPreviewPossible) { - let asciiLength = 0; - while (asciiLength < value.length && value[asciiLength] > 0 && value[asciiLength] < 128) - asciiLength++; - asciiPrefixLength += asciiLength; - if (value[asciiLength] === 0) { - // A split UTF-16 prefix must not leave an ASCII preview behind. - pre.textContent = ""; - block = null; - blockLength = 0; - displayedSampleSize = 0; - asciiPrefixLength = 0; - asciiPreviewPossible = false; - } else if (asciiLength < value.length) asciiPreviewPossible = false; - } - if (asciiPrefixLength >= 4 && asciiPrefixLength > displayedSampleSize) { - // The full sample remains for encoding detection; preview only its - // unseen ASCII prefix instead of revisiting all previous chunks. - while (displayedSampleSize < asciiPrefixLength) { - const chunk = sampleChunks[previewChunkIndex]; - const end = Math.min(chunk.length, previewChunkOffset + asciiPrefixLength - displayedSampleSize); - await appendDecoded(previewDecoder.decode(chunk.subarray(previewChunkOffset, end))); - displayedSampleSize += end - previewChunkOffset; - previewChunkOffset = end; - if (end === chunk.length) { - previewChunkIndex++; - previewChunkOffset = 0; - } - } - } - if (sampleLength < 65540) continue; - await startDecoder(); - } else { - await appendDecoded(decoder.decode(value, { stream: true })); - } - } - if (!decoder) await startDecoder(); - await appendDecoded(decoder.decode()); - } catch (reason) { - try { - await reader.cancel(reason); - } catch (_) {} - throw reason; - } finally { - untrack(); - reader.releaseLock(); - } -} -function detectTextEncoding(bytes, hint = "") { - if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return "utf-8"; - if (bytes[0] === 0xff && bytes[1] === 0xfe) return "utf-16le"; - if (bytes[0] === 0xfe && bytes[1] === 0xff) return "utf-16be"; - const evenNulls = bytes.filter((value, index) => !value && index % 2 === 0).length; - const oddNulls = bytes.filter((value, index) => !value && index % 2 === 1).length; - if (oddNulls > bytes.length / 8 && oddNulls > evenNulls * 4) return "utf-16le"; - if (evenNulls > bytes.length / 8 && evenNulls > oddNulls * 4) return "utf-16be"; - try { - new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true }); - return "utf-8"; - } catch (_) {} - if (/[\u0400-\u04ff]/.test(hint)) return "windows-1251"; - const candidates = /[\u3400-\u9fff]/.test(hint) - ? ["gb18030", "big5"] - : ["gb18030", "big5", "windows-1251", "windows-1252"]; - let best = "gb18030", - bestScore = -Infinity; - for (const encoding of candidates) { - try { - const text = new TextDecoder(encoding).decode(bytes); - const controls = (text.match(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g) || []).length; - const replacements = (text.match(/\ufffd/g) || []).length; - const cjk = (text.match(/[\u3400-\u9fff]/g) || []).length; - const cyrillic = (text.match(/[\u0400-\u04ff]/g) || []).length; - const commonCjk = ( - text.match( - /[的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经之进着等部家自理起现实都体制当本性应开合因由然前外政社义事相全与关各重新内正反明原利质向道命此变结解问意建公系军情者立代通题党程展料员革文总品活长求老基资级图统知组别期论运农指区战任处理世]/g - ) || [] - ).length; - const score = Math.max(cjk + commonCjk * 5, cyrillic) - controls * 20 - replacements * 40; - if (score > bestScore) { - best = encoding; - bestScore = score; - } - } catch (_) {} - } - return best; -} -async function renderChapterManifest(prepared) { - const response = await prepared; - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const manifest = VoiceOfMLReaderSecurity.validateChapterManifest( - JSON.parse( - await VoiceOfMLReaderSecurity.readText(response, VoiceOfMLReaderSecurity.LIMITS.manifestBytes) - ) - ); - assertReaderActive(); - const frame = document.createElement("div"); - frame.className = "epub-frame"; - frame.style.overflowAnchor = "none"; - const openingGeneration = readerRuntime.currentGeneration("navigation"); - content.appendChild(frame); - const loaded = new Set(), retainedBytes = new Map(), - manifestBase = trustedReaderAssetManifestUrl(chapterManifestUrl || sourceUrl); - const chapterByIndex = new Map(manifest.chapters.map((chapter) => [chapter.index, chapter])); - const chapterPrefetchCount = 6; - const chapterPrefetchMaxCount = 10, chapterPrefetchBytes = 128 * 1024; - let chapterFocusIndex = 1, chapterWindowFrame = 0, chapterWindowReady = false; - let chapterNavigating = 0; - let chapterPreviousObserver = null; - const chapterBudget = VoiceOfMLReaderSecurity.createByteBudget(); - if (!manifestBase) throw new Error("EPUB_INVALID_MANIFEST_PATH"); - const searchIndex = manifest.search_index; - chapterSearchConfiguration = - searchIndex?.path === "epub-search-index.json.gz" && - Number.isSafeInteger(searchIndex.bytes) && - searchIndex.bytes > 0 && - /^[0-9a-f]{64}$/.test(searchIndex.sha256 || "") - ? { - chapters: manifest.chapters, - index: { - ...searchIndex, - url: (() => { - const resolved = trustedChapterUrl(searchIndex.path, manifestBase); - if (!resolved) return ""; - const target = new URL(resolved, location.href); - const bucketUrl = target.origin === "https://voiceofml-search.hf.space" && - target.pathname === "/api/reader-bucket-resource" - ? target.pathname + target.search : resolved; - return readerContentUrl(bucketUrl); - })() - } - } - : null; - const chapterUrl = (chapter) => trustedChapterUrl(chapter.path, manifestBase); - const chapterAt = (index) => chapterByIndex.get(index) || null; - const nextChapterNode = (index, selector) => { - for (const node of frame.querySelectorAll(selector)) - if (Number(node.dataset.chapter) > index) return node; - return null; - }; - const resourceUrl = (raw, base, attribute) => { - const value = String(raw || "").trim(); - if (!value || value.startsWith("#")) return null; - return trustedChapterResourceUrl(value, base, attribute, manifestBase); - }; - const insertChapter = (article) => { - const existing = frame.querySelector( - `.reader-epub-chapter[data-chapter="${article.dataset.chapter}"]` - ); - if (existing) return existing; - const next = nextChapterNode(Number(article.dataset.chapter), ".reader-epub-chapter, .reader-chapter-sentinel"); - const marker = frame.querySelector(`.reader-chapter-sentinel[data-chapter="${article.dataset.chapter}"]`); - const insert = () => { - if (marker) { - chapterManifestObserver.unobserve(marker); - chapterPreviousObserver.unobserve(marker); - marker.replaceWith(article); - } else frame.insertBefore(article, next || null); - }; - const preservePosition = chapterWindowReady || !isReaderGenerationCurrent("navigation", openingGeneration); - if (preservePosition) foliateScrollAnchors.preserve(insert); - else insert(); - chapterManifestObserver.observe(article); - if (preservePosition) foliateScrollAnchors.observe(article); - return article; - }; - const ensureSentinel = (chapter, previous = false) => { - if (readerAbortController.signal.aborted || loaded.has(chapter.index)) return null; - const selector = `.reader-chapter-sentinel[data-chapter="${chapter.index}"]`; - const existing = frame.querySelector(selector); - if (existing) return existing; - const marker = document.createElement("button"); - marker.type = "button"; - marker.className = "reader-chapter-sentinel"; - marker.dataset.chapter = String(chapter.index); - marker.textContent = `加载第 ${chapter.index} 章`; - marker.setAttribute("aria-label", `重试加载第 ${chapter.index} 章`); - marker.addEventListener("click", () => { - marker.disabled = true; - fetchChapter(chapter).catch((error) => { - marker.disabled = false; - console.warn("EPUB chapter could not be loaded", error); - }); - }); - const next = nextChapterNode(chapter.index, - ".reader-epub-chapter[data-chapter], .reader-chapter-sentinel[data-chapter]"); - const insert = () => frame.insertBefore(marker, next || null); - if (chapterWindowReady || !isReaderGenerationCurrent("navigation", openingGeneration)) - foliateScrollAnchors.preserve(insert); - else insert(); - (previous ? chapterPreviousObserver : chapterManifestObserver).observe(marker); - return marker; - }; - const chapterTarget = (article, fragment) => - !fragment || article.id === fragment ? article : - article.querySelector(`[id="${CSS.escape(fragment)}"], [name="${CSS.escape(fragment)}"]`); - const setChapterToc = () => - setToc( - (manifest.toc ?? manifest.chapters.map((item) => ({ - title: item.title || `章节 ${item.index}`, chapter: item.index, depth: 0, fragment: "" - }))).map((item) => ({ - label: item.title, - chapterIndex: item.chapter, - fragment: item.fragment, - depth: item.depth, - activate: (generation) => activateChapter(manifest.chapters[item.chapter - 1], generation, item.fragment) - })) - ); - const chapterScheduler = VoiceOfMLReaderChapters.createChapterScheduler({ - create: async (index, signal, priority) => { - const chapter = chapterAt(index); - assertReaderActive(); - const url = chapterUrl(chapter); - if (!url) throw new Error("EPUB_INVALID_PATH"); - const result = await fetchReaderUrl(url, { signal, priority }); - if (!result.ok) throw new Error(`HTTP ${result.status}`); - const bytes = await VoiceOfMLReaderSecurity.readBytes( - result, - VoiceOfMLReaderSecurity.LIMITS.chapterBytes, - chapterBudget - ); - let inserted = false; - try { - assertReaderActive(); - signal.throwIfAborted(); - const doc = parseChapterDocument(new TextDecoder().decode(bytes)); - for (const element of doc.querySelectorAll("*")) { - for (const name of ["src", "href", "poster", "xlink:href"]) - if (element.hasAttribute(name)) { - const value = element.getAttribute(name) || ""; - if (name === "href" && element.localName === "a" && - trustedChapterReferenceUrl(value, url, manifestBase)) continue; - const resolved = resourceUrl(value, url, name === "href" ? "href" : "resource"); - if (resolved) element.setAttribute(name, resolved); - else if (!(name === "href" && value.startsWith("#"))) element.removeAttribute(name); - } - if (element.hasAttribute("srcset")) element.removeAttribute("srcset"); - } - const article = document.createElement("article"); - article.className = "reader-markdown reader-epub-chapter"; - article.dataset.chapter = String(chapter.index); - if (doc.body?.id) article.id = doc.body.id; - article.innerHTML = doc.body ? doc.body.innerHTML : ""; - for (const image of article.querySelectorAll("img")) { - image.loading = priority === "high" ? "eager" : "lazy"; - image.fetchPriority = priority; - image.decoding = "async"; - } - // The generated text index includes head text (e.g. the chapter title). - article._chapterSearchHead = doc.head ? document.importNode(doc.head, true) : null; - insertChapter(article); - loaded.add(chapter.index); - retainedBytes.set(chapter.index, bytes.byteLength); - inserted = true; - for (const entry of navigationState.tocEntries) - if (entry.chapterIndex === chapter.index) - entry.target = chapterTarget(article, entry.fragment); - const next = chapterAt(chapter.index + 1); - if (next) ensureSentinel(next); - const previous = chapterAt(chapter.index - 1); - if (previous) ensureSentinel(previous, true); - scheduleChapterWindow(); - } catch (error) { - if (!inserted) chapterBudget.release(bytes.byteLength); - throw error; - } - } - }); - const fetchChapter = (chapter, demand = true, exclusive = false) => { - assertReaderActive(); - if (loaded.has(chapter.index)) return Promise.resolve(); - return chapterScheduler.load(chapter.index, demand, exclusive).then(() => { - foliateScrollAnchors.whenIdle(trimChapterWindow); - }).catch(error => { - if (error.name !== "AbortError" && !readerAbortController.signal.aborted) { - const marker = ensureSentinel(chapter); - marker?.classList.add("reader-chapter-load-error"); - marker?.removeAttribute("aria-hidden"); - } - throw error; - }); - }; - const activateChapter = async (chapter, generation, fragment = "") => { - chapterFocusIndex = chapter.index; - chapterNavigating++; - try { await fetchChapter(chapter, true, chapterWindowReady); } - finally { chapterNavigating--; } - if (!isReaderGenerationCurrent("navigation", generation)) return false; - const node = frame.querySelector(`.reader-epub-chapter[data-chapter="${chapter.index}"]`); - let id = fragment.replace(/^#/, ""); - try { - id = decodeURIComponent(id); - } catch (_) {} - const anchor = chapterTarget(node, id); - if (!anchor) throw new Error("EPUB_MISSING_ANCHOR"); - foliateScrollAnchors.invalidate(); - anchor.scrollIntoView({ block: "start" }); - foliateScrollAnchors.remember(); - syncHeadingLocation(); - updateProgressTools(); - scheduleSave(); - return true; - }; - frame.addEventListener("click", async (event) => { - const link = event.target.closest?.("a[href]"); - const article = link?.closest(".reader-epub-chapter"); - if ( - !article || - event.button !== 0 || - event.ctrlKey || - event.metaKey || - event.shiftKey || - event.altKey - ) - return; - const current = chapterAt(Number(article.dataset.chapter)); - const destination = trustedChapterReferenceUrl(link.getAttribute("href"), chapterUrl(current), manifestBase); - const target = destination ? new URL(destination.url) : null; - const fragment = destination?.fragment || ""; - const chapter = target && manifest.chapters.find((item) => chapterUrl(item) === target.href); - if (!chapter) { - // A stale or missing book document must not navigate out to an asset 404. - if (/\.(?:xhtml?|html?)(?:$|#)/i.test(link.getAttribute("href") || "")) { - event.preventDefault(); - let message = link.nextElementSibling; - if (!message?.classList.contains("reader-chapter-link-error")) { - message = document.createElement("span"); - message.className = "reader-chapter-link-error"; - message.setAttribute("role", "status"); - message.textContent = "(无法定位此书内链接)"; - link.after(message); - } - } - return; - } - event.preventDefault(); - const generation = beginReaderNavigation(); - try { - await activateChapter(chapter, generation, fragment); - } catch (error) { - reportNavigationError(error, generation); - } - }); - const chapterVirtualizer = VoiceOfMLReaderVirtual.createSectionVirtualizer({ - limit: 12, - getLoaded: () => [...frame.querySelectorAll(":scope > .reader-epub-chapter")], - getIndex: article => Number(article.dataset.chapter), - getHeight: article => article.getBoundingClientRect().height, - canVirtualize: article => { - const rect = article.getBoundingClientRect(), view = viewport.getBoundingClientRect(); - const selection = window.getSelection(); - return (rect.bottom < view.top - 800 || rect.top > view.bottom + 800) && - !article.contains(document.activeElement) && - !article.querySelector("mark.full-search-highlight") && - !(selection && !selection.isCollapsed && - selection.containsNode(article, true)); - }, - virtualize: (article, index, height) => { - loaded.delete(index); - const marker = ensureSentinel(chapterAt(index)); - const style = getComputedStyle(article); - marker.classList.add("reader-chapter-placeholder"); - marker.style.height = `${height}px`; - marker.style.marginTop = style.marginTop; - marker.style.marginBottom = style.marginBottom; - marker.setAttribute("aria-hidden", "true"); - chapterManifestObserver.unobserve(article); - foliateScrollAnchors.unobserve(article); - for (const entry of navigationState.tocEntries) - if (entry.chapterIndex === index) entry.target = null; - for (const image of article.querySelectorAll("img")) image.removeAttribute("src"); - article.replaceWith(marker); - article._chapterSearchHead = null; - }, - release: index => { - chapterBudget.release(retainedBytes.get(index)); - retainedBytes.delete(index); - }, - preserve: change => foliateScrollAnchors.preserve(change) - }); - function trimChapterWindow() { - if (chapterWindowReady) chapterVirtualizer.trim(chapterFocusIndex); - } - function chapterPrefetchIndices() { - if (document.hidden) return []; - // A fixed opening prefix keeps extra front matter within the DOM retention target. - const forwardCount = Math.max(chapterPrefetchCount, chapterPrefetchMaxCount + 1 - chapterFocusIndex); - const nearby = manifest.chapters.slice(Math.max(0, chapterFocusIndex - 2), - chapterFocusIndex + forwardCount); - const indices = []; - let bytes = 0; - for (const chapter of nearby) { - bytes += chapter.bytes; - if (chapter.index > chapterFocusIndex + chapterPrefetchCount && bytes > chapterPrefetchBytes) break; - if (!loaded.has(chapter.index)) indices.push(chapter.index); - } - return indices; - } - function refreshChapterWindow() { - chapterWindowFrame = 0; - if (!chapterWindowReady || readerAbortController.signal.aborted) return; - const view = viewport.getBoundingClientRect(); - const rows = [...frame.querySelectorAll(":scope > [data-chapter]")]; - const current = rows.find(row => { - const rect = row.getBoundingClientRect(); - return rect.top <= view.top + 80 && rect.bottom > view.top + 80; - }) || rows.find(row => row.getBoundingClientRect().top > view.top); - if (current && !chapterNavigating) chapterFocusIndex = Number(current.dataset.chapter); - if (!chapterNavigating && current?.classList.contains("reader-chapter-sentinel") && - current.getBoundingClientRect().top < view.bottom) - fetchChapter(chapterAt(chapterFocusIndex)).catch(() => {}); - if (!chapterNavigating) chapterScheduler.prefetch(chapterPrefetchIndices()); - foliateScrollAnchors.whenIdle(trimChapterWindow); - } - function scheduleChapterWindow() { - if (!chapterWindowFrame) chapterWindowFrame = requestAnimationFrame(refreshChapterWindow); - } - function handleChapterVisibility() { - if (document.hidden) chapterScheduler.prefetch([]); - else scheduleChapterWindow(); - } - chapterManifestObserver = new IntersectionObserver( - scheduleChapterWindow, - { root: viewport, rootMargin: "800px 0px" } - ); - chapterPreviousObserver = new IntersectionObserver((entries) => { - if (!chapterWindowReady || chapterNavigating || readerAbortController.signal.aborted) return; - for (const entry of entries) { - if (!entry.isIntersecting) continue; - const chapter = chapterAt(Number(entry.target.dataset.chapter)); - if (chapter) fetchChapter(chapter).catch(() => {}); - } - }, { root: viewport }); - viewport.addEventListener("scroll", scheduleChapterWindow, { passive: true }); - window.addEventListener("resize", scheduleChapterWindow); - document.addEventListener("visibilitychange", handleChapterVisibility); - let chapterCleaned = false; - chapterManifestCleanup = () => { - if (chapterCleaned) return; - chapterCleaned = true; - chapterWindowReady = false; - cancelAnimationFrame(chapterWindowFrame); - viewport.removeEventListener("scroll", scheduleChapterWindow); - window.removeEventListener("resize", scheduleChapterWindow); - document.removeEventListener("visibilitychange", handleChapterVisibility); - chapterScheduler.dispose(); - chapterVirtualizer.dispose(); - chapterManifestObserver?.disconnect(); - chapterPreviousObserver?.disconnect(); - chapterManifestObserver = null; - chapterManifestLoader = null; - for (const bytes of retainedBytes.values()) chapterBudget.release(bytes); - retainedBytes.clear(); - loaded.clear(); - }; - trackReaderResource(chapterManifestCleanup); - chapterManifestLoader = async (index) => { - const chapter = chapterAt(index); - if (!chapter) return null; - chapterFocusIndex = index; - chapterNavigating++; - try { await fetchChapter(chapter, true, chapterWindowReady); } - finally { chapterNavigating--; } - return frame.querySelector(`.reader-epub-chapter[data-chapter="${index}"]`); - }; - chapterManifestLoader.count = manifest.chapters.length; - setChapterToc(); - await getInitialReaderRestoration(); - const savedChapter = Number(readerRestorationEntry?.chapterIndex) - 1; - const initialChapter = Number.isInteger(savedChapter) - ? Math.max(0, Math.min(manifest.chapters.length - 1, savedChapter)) - : 0; - chapterFocusIndex = manifest.chapters[initialChapter].index; - if (initialChapter > 0) ensureSentinel(manifest.chapters[initialChapter - 1], true); - try { - const firstChapter = fetchChapter(manifest.chapters[initialChapter]); - chapterScheduler.prefetch(chapterPrefetchIndices()); - await firstChapter; - } catch (error) { - if (readerRestorationEntry?.chapterIndex == null) throw error; - throw Object.assign(new Error("阅读位置恢复失败,原进度已保留,请重试加载。", { cause: error }), { - code: "READER_RESTORE" - }); - } - chapterWindowReady = true; - if (isReaderGenerationCurrent("navigation", openingGeneration)) foliateScrollAnchors.invalidate(); - for (const article of frame.querySelectorAll(".reader-epub-chapter")) foliateScrollAnchors.observe(article); - frame.style.overflowAnchor = "none"; - scheduleChapterWindow(); - assertReaderActive(); - markReaderContentReady(); - status.textContent = `EPUB · ${manifest.chapters.length} 章`; -} -async function renderDocx(prepared) { - const bytes = await readPreparedBytes(prepared.response, prepared.engines); - assertReaderActive(); - VoiceOfMLReaderSecurity.inspectZip(bytes, { - ...VoiceOfMLReaderSecurity.LIMITS, - archiveCompressedBytes: VoiceOfMLReaderSecurity.LIMITS.documentBytes - }); - const styles = document.createElement("div"); - styles.className = "docx-styles"; - const body = document.createElement("div"); - body.className = "docx-body"; - content.append(styles, body); - const options = { - className: "reader-docx", - inWrapper: true, - breakPages: true, - ignoreLastRenderedPageBreak: false, - useBase64URL: false, - renderHeaders: true, - renderFooters: true, - renderFootnotes: true, - renderEndnotes: true, - renderChanges: false, - renderComments: false, - renderAltChunks: false, - debug: false - }; - const docxDocument = await awaitReader(docx.parseAsync(bytes, options)); - assertReaderActive(); - const urls = new Set(), urlsByPath = new Map(), contentTypes = docxDocument.contentTypes || []; - let released = false; - const release = () => { - released = true; - for (const url of urls) URL.revokeObjectURL(url); - urls.clear(); - urlsByPath.clear(); - }; - const untrack = trackReaderResource(release); - memoizeDocxAssets(docxDocument, () => released); - // Own this parsed document's images, fonts and numbering assets only. - docxDocument.blobToURL = (blob, path) => { - if (released || !blob) return null; - if (path && urlsByPath.has(path)) return urlsByPath.get(path); - const type = path && contentTypes.find(entry => entry.partName === path || - (entry.extension && path.endsWith(`.${entry.extension}`))); - const url = URL.createObjectURL(type ? new Blob([blob], {type:type.contentType}) : blob); - if (typeof url === "string" && url.startsWith("blob:")) { - urls.add(url); - if (path) urlsByPath.set(path, url); - } - return url; - }; - try { - const nodes = await awaitReader(docx.renderDocument(docxDocument, options)); - assertReaderActive(); - for (const node of nodes) (node.nodeName === "STYLE" ? styles : body).appendChild(node); - } catch (error) { - release(); - untrack(); - throw error; - } - assertReaderActive(); - body.classList.toggle("reader-document-dark", readerTheme === "dark"); - if (!(body.textContent || "").trim() && !body.querySelector("img, table, svg, canvas")) { - release(); - untrack(); - throw new Error("DOCX rendered no supported content"); - } - const pages = [...body.querySelectorAll(":scope > .reader-docx-wrapper > section.reader-docx")]; - docxPageNodes = pages; - if (pages.length) { - updateDocumentState({ pageCount: pages.length }); - pageInput.max = String(documentState.pageCount); - document.querySelector("#page-total").textContent = `/ ${documentState.pageCount}`; - document.querySelector(".page-controls").hidden = false; - pages.forEach((page, index) => { - page.classList.add("reader-docx-page"); - page.dataset.page = String(index + 1); - page.style.setProperty("--reader-docx-height", page.style.minHeight || page.style.height || "1123px"); - }); - setToc(headingTocEntries(body)); - } - for (const link of body.querySelectorAll("a[href]")) { - const href = link.getAttribute("href") || ""; - if (!href.startsWith("#") && !/^https?:\/\//i.test(href)) link.removeAttribute("href"); - else if (!href.startsWith("#")) { - link.target = "_blank"; - link.rel = "noopener noreferrer"; - } - } - markReaderContentReady(); - status.textContent = documentState.pageCount ? `${documentState.pageCount} 页` : "DOCX"; -} -function renderImageDocument(image) { - assertReaderActive(); - content.appendChild(image); - markReaderContentReady(); - status.textContent = "图片"; -} -function disposeFormatResources(mode) { - if (mode === "epub-chapters") { - chapterManifestCleanup?.(); - chapterManifestCleanup = null; - } - if (mode === "swf") { - swfPlayer?.remove(); - swfPlayer = null; - } - if (["pdf", "pdf-pages", "image-pages"].includes(mode)) { - pdfDocument = null; - pdfHybridUrl = ""; - pdfHybridDocumentPromise = null; - pdfPageManifest = null; - pdfOcrManifest = null; - pdfOcrManifestPromise = null; - pdfV3Repository?.dispose(); pdfTextLayerRepository?.dispose(); - for (const shell of pdfManifestShells) shell?._v3PreviewRelease?.(); - pdfV3Repository = pdfTextLayerRepository = null; - pdfV3Manifest = pdfV3Map = null; - pdfTextContentCache.clear(); - pdfTextContentPromises.clear(); - pdfTextContentCacheBytes = 0; - pdfOcrPageCache.clear(); - pdfOcrPageCacheBytes = 0; - clearPdfManifestPrefetches(); - pdfBookSearchClient?.dispose(); - pdfBookSearchClient = null; - pdfShellWindow?.dispose(); - pdfFirstPagePreload?.removeAttribute("src"); - pdfFirstPagePreload = null; - for (const canvas of content.querySelectorAll(".reader-page canvas")) { - canvas.width = 0; - canvas.height = 0; - } - for (const image of content.querySelectorAll(".reader-page img")) image.removeAttribute("src"); - } - if (mode === "foliate") { - foliateSectionObserver?.disconnect(); - foliateSectionObserver = null; - foliateSectionVirtualizer?.dispose(); - foliateChapterRepository?.dispose(); - foliateSectionVirtualizer = null; - foliateChapterRepository = null; - foliateSectionLoader = null; - foliateSectionSettler = null; - try { - const closing = epubRendition?.close?.(); - closing?.catch?.(() => {}); - } catch (_) {} - epubRendition = null; - epubBook = null; - } - if (mode === "html") { - htmlFrame?.remove(); - htmlFrame = null; - content.classList.remove("reader-content--spreadsheet"); - } - if (["audio", "video"].includes(mode) && mediaElement) { - mediaElement.pause(); - mediaElement.removeAttribute("src"); - mediaElement.load(); - mediaElement = null; - } -} -function renderTextDocument(prepared) { - return renderText(false, prepared); -} -function renderMarkdownDocument(prepared) { - return renderText(true, prepared); -} -function captureChapterPosition() { - if (capability.mode !== "epub-chapters") return null; - const marker = viewport.getBoundingClientRect().top + 8; - const chapters = [...content.querySelectorAll(".reader-epub-chapter")]; - const chapter = - chapters.find((node) => node.getBoundingClientRect().bottom > marker) || - chapters[chapters.length - 1]; - return chapter - ? { - chapterIndex: Number(chapter.dataset.chapter), - chapterManifest: chapterManifestUrl || sourceUrl, - chapterOffset: marker - chapter.getBoundingClientRect().top - } - : null; -} -async function restoreChapterPosition(entry, generation) { - if ( - /-epub-chapters-v5\//.test(chapterManifestUrl || sourceUrl) && - entry.chapterManifest !== (chapterManifestUrl || sourceUrl) - ) return false; - if (!chapterManifestLoader || !Number.isInteger(entry.chapterIndex)) return false; - const chapter = await chapterManifestLoader(entry.chapterIndex); - if (!chapter?.isConnected || !isReaderGenerationCurrent("navigation", generation)) return false; - viewport.scrollTop += - chapter.getBoundingClientRect().top - - viewport.getBoundingClientRect().top - - 8 + - (Number(entry.chapterOffset) || 0); - foliateScrollAnchors.remember(); - syncHeadingLocation(); - updateProgressTools(); - scheduleSave(); - return true; -} -async function restoreFormat(mode, entry, generation = beginReaderNavigation()) { - try { - if (!entry || !isReaderGenerationCurrent("navigation", generation)) return false; - if (mode === "epub-chapters") { - if (Number.isInteger(entry.chapterIndex)) { - if (!(await restoreChapterPosition(entry, generation))) return false; - } else if (!(await restoreProgressState(entry, generation))) return false; - } else if (mode === "foliate" && Number.isInteger(entry.foliateSection)) { - if (!(await restoreFoliateBookmarkPosition(entry, generation))) return false; - } else if (documentState.pageCount && entry.page) { - if ( - !(await goToPage(entry.page, generation)) || - !isReaderGenerationCurrent("navigation", generation) - ) - return false; - const shell = content.querySelector( - `.reader-page[data-page="${documentState.page}"], .reader-docx-page[data-page="${documentState.page}"]` - ); - if (shell && Number.isFinite(entry.pageOffset)) - viewport.scrollTop = readerPageScrollTop(shell) + entry.pageOffset; - updateProgressTools(); - scheduleSave(); - } else if (!(await restoreProgressState(entry, generation))) return false; - if (!isReaderGenerationCurrent("navigation", generation)) return false; - consumeBookmarkHandoff(entry); - return true; - } catch (error) { - reportNavigationError(error, generation); - return false; - } -} -async function restoreInitialPosition(entry, generation) { - if (!entry || !isReaderGenerationCurrent("navigation", generation)) return; - const restored = await formatAdapters.active.restore(entry, generation); - if (!restored && isReaderGenerationCurrent("navigation", generation)) - throw Object.assign(new Error("阅读位置恢复失败,原进度已保留,请重试加载。"), { - code: "READER_RESTORE" - }); -} -async function applyInitialRestoration(entry, generation) { - updateDocumentState({ restoredEntry: entry }); - if (entry?.zoom && !zoomChangedByUser && isReaderGenerationCurrent("navigation", generation)) - setZoom(entry.zoom, false); - await restoreInitialPosition(entry, generation); -} -function registerReaderFormatAdapters() { - const formats = { - "pdf-pages": [ - () => { - return fetchReaderResponse(); - }, - renderPdfPages - ], - "image-pages": [ - () => fetchReaderResponse(), - renderImagePages - ], - pdf: [loadPdfDocument, renderPdf], - image: [loadImageDocument, renderImageDocument], - text: [loadTextDocument, renderTextDocument], - markdown: [loadMarkdownDocument, renderMarkdownDocument], - html: [loadHtmlDocument, renderHtml], - "epub-chapters": [loadChapterManifestDocument, renderChapterManifest], - docx: [loadDocxDocument, renderDocx], - audio: [loadMediaDocument, () => renderMedia("audio")], - video: [loadMediaDocument, () => renderMedia("video")], - swf: [loadSwfDocument, renderSwf], - foliate: [loadMediaDocument, renderFoliate] - }; - for (const [mode, [open, render]] of Object.entries(formats)) - formatAdapters.register(mode, { - open, - render, - restore: (entry, generation) => restoreFormat(mode, entry, generation), - dispose: () => disposeFormatResources(mode) - }); -} -function renderMedia(mode) { - assertReaderActive(); - const media = document.createElement(mode); - media.className = mode === "audio" ? "reader-audio" : "reader-video"; - media.controls = true; - media.setAttribute("aria-label", `${documentState.title} ${mode === "audio" ? "音频" : "视频"}`); - media.preload = "metadata"; - if (mode === "video") media.playsInline = true; - const refresh = () => { - if (readerAbortController.signal.aborted) return; - mediaPanel.querySelector(".media-panel-time").textContent = - `${formatMediaTime(media.currentTime)} / ${formatMediaTime(media.duration)}`; - scheduleMarkerSync(); - }; - for (const type of ["timeupdate", "durationchange", "loadedmetadata"]) - media.addEventListener(type, refresh, { passive: true }); - for (const type of ["pause", "ended", "seeked"]) - media.addEventListener(type, () => { - refresh(); - saveProgress(); - }); - mediaElement = media; - let sourceRetried = false; - const originalMode = VoiceOfMLReader.capability(resolvedReaderData?.original_extension || extension).mode; - const retryUrl = originalMode === mode && validSource(downloadUrl) ? downloadUrl : sourceUrl; - media.addEventListener("error", () => { - if (readerAbortController.signal.aborted) return; - if (!sourceRetried && retryUrl !== media.src && validSource(retryUrl)) { - sourceRetried = true; - media._readerSourceRetry = true; - media.src = retryUrl; - media.load(); - return; - } - media._readerSourceRetry = false; - fail("媒体加载失败,请检查网络后重试,或下载原文件。", "READER_MEDIA"); - }); - media.src = contentUrl; - content.appendChild(media); - markReaderContentReady(); - status.textContent = mode === "audio" ? "音频" : "视频"; -} -async function loadSwfDocument() { - await loadScript(RUFFLE_URL); - const response = await fetchReaderResponse(); - return new Uint8Array(await response.arrayBuffer()); -} -async function renderSwf(data) { - assertReaderActive(); - const factory = window.RufflePlayer?.newest?.(); - if (!factory) throw Object.assign(new Error("Ruffle 未加载"), { code: "READER_SWF_ENGINE" }); - const player = factory.createPlayer(); - player.className = "reader-swf"; - player.setAttribute("aria-label", `${documentState.title} Flash 内容`); - player.style.width = "100%"; - player.style.minHeight = "360px"; - content.appendChild(player); - swfPlayer = player; - await player.ruffle().load({ data }); - markReaderContentReady(); - status.textContent = "Flash 内容"; -} // Resolve once, then keep source URLs, capability, and presentation in sync. -async function resolveReaderSource() { - if (!readerId || sourceUrl) return; - setReaderStage("resolve"); - let resolved = cachedReaderData; - if (!resolved?.url) { - try { - resolved = await resolveReaderId(readerId); - } catch (error) { - assertReaderActive(); - if (!localReaderData?.url) throw error; - resolved = localReaderData; - } - } - assertReaderActive(); - sourceUrl = normalizeSourceUrl(resolved.url || ""); - contentUrl = readerContentUrl(sourceUrl); - downloadUrl = resolved.download || sourceUrl; - chapterManifestUrl = resolved.chapter_manifest || ""; - fallbackUrl = resolved.fallback || ""; - if (resolved.extension) extension = String(resolved.extension).toLowerCase(); - if ( - extension === "pdf" && - sourceUrl.includes("/api/reader-bucket-resource?") && - new URL(sourceUrl).searchParams.get("path")?.endsWith("page-manifest.json") - ) - extension = "pdf-pages"; - capability = readerRuntime.negotiate(VoiceOfMLReader.capability(extension)); - if (capability.mode !== "pdf" && !pdfPrimarySource()) releasePdfWorker?.(); - readerRuntime.update("source", { - url: sourceUrl, - contentUrl, - downloadUrl, - extension, - metadata: resolved - }); - applyReaderMetadata(resolved); -} -async function start() { - const generation = readerRuntime.currentGeneration("navigation"); - // A PDF link already tells us which engine is needed. Start its import while - // an ID-only Reader link is resolving its source URL. The promise is reused - // by loadPdfDocument, so this does not create a second module request. - if (extension === "pdf" || /\.pdf(?:$|[?#])/i.test(sourceUrl)) preloadPdfEngine().catch(() => {}); - readerSourcePromise = measurePdf(resolveReaderSource(), "resolve"); - await readerSourcePromise; - assertReaderActive(); - if (readerId && /\/calibre-chm-epub-[^/]+\/document\.epub(?:$|\?)/i.test(sourceUrl)) { - extension = "epub"; - readerRuntime.update("source", { extension }); - capability = readerRuntime.negotiate(VoiceOfMLReader.capability(extension)); - content.dataset.mode = capability.mode; - document.querySelector(".page-controls").hidden = true; - } - syncCapabilityControls(); - downloadButton.href = readerDownloadUrl(documentState.title, downloadUrl); - if ( - (!validSource(sourceUrl) && !validSource(chapterManifestUrl) && !readerId) || - capability.readerMode === VoiceOfMLReader.ReaderMode.UNSUPPORTED - ) - return fail("此文件暂不支持在线阅读,请下载原文件。", "READER_UNSUPPORTED"); - formatAdapters.activate(capability.mode); - applyReaderMetadata(resolvedReaderData); - if (validOcr(ocrUrl)) { - const ocr = document.querySelector("#ocr") || document.createElement("a"); - ocr.id = "ocr"; - ocr.className = "text-button"; - ocr.target = "_blank"; - ocr.rel = "noopener noreferrer"; - ocr.textContent = "OCR"; - if (!ocr.parentElement) document.querySelector(".reader-actions")?.prepend(ocr); - ocr.href = ocrUrl; - ocr.hidden = false; - } - try { - if (capability.mode === "foliate") { - setReaderStage("foliate"); - foliateContinuous = true; - const restorationPromise = loadReaderRestoration(); - readerRestorationPromise = restorationPromise; - await awaitReader(renderFoliate()); - assertReaderActive(); - const restored = await awaitReader(restorationPromise); - await applyInitialRestoration(restored, generation); - initialReaderTargetSection = null; - assertReaderActive(); - if (!setReaderPhase("ready")) return; - updateDocumentState({ restorationReady: !restorationFailed }); - scheduleSave(); - return; - } - setReaderStage("prepare"); - const restorationPromise = loadReaderRestoration(); - readerRestorationPromise = restorationPromise; - const prepareResources = new Set(readerResources); - try { - const prepared = await awaitReader(formatAdapters.active.open()); - assertReaderActive(); - await formatAdapters.active.render(prepared); - } catch (error) { - assertReaderActive(); - const originalExtension = String(resolvedReaderData?.original_extension || "").toLowerCase(); - if (capability.mode !== "image-pages" || !validSource(downloadUrl) || - !["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(originalExtension)) throw error; - for (const cleanup of [...readerResources]) { - if (prepareResources.has(cleanup)) continue; - cleanup(); - readerResources.delete(cleanup); - } - formatAdapters.dispose(); - content.replaceChildren(); - extension = originalExtension; - capability = readerRuntime.negotiate(VoiceOfMLReader.capability(extension)); - readerRuntime.update("source", { extension }); - updateDocumentState({ page: 1, pageCount: 0 }); - formatAdapters.activate(capability.mode); - syncCapabilityControls(); - await renderImageDocument(await loadImageDocument(downloadUrl)); - } - assertReaderActive(); - markReaderContentReady(); - const restored = await awaitReader(restorationPromise); - await applyInitialRestoration(restored, generation); - assertReaderActive(); - if (!setReaderPhase("ready")) return; - updateDocumentState({ restorationReady: !restorationFailed }); - schedulePdfBookSearchPrefetch(); - updateProgressTools(); - if (fullSearchInput.value.trim()) runFullSearch(); - scheduleSave(); - } catch (error) { - if (readerAbortController.signal.aborted || error?.name === "AbortError") return; - console.error(error); - if ( - error?.code !== "READER_RESTORE" && - capability.mode === "epub-chapters" && - validFallback(fallbackUrl) - ) { - const target = new URL(location.href); - target.searchParams.set("url", fallbackUrl); - target.searchParams.set("ext", "pdf"); - target.searchParams.delete("chapter_manifest"); - target.searchParams.delete("fallback"); - if (window.parent !== window) - window.parent.postMessage({ type: "voice-reader-open", url: target.href }, location.origin); - else location.replace(target.href); - return; - } - fail( - error && error.message ? error.message : "原文件加载失败", - classifyReaderError(error, capability.mode === "foliate" ? "READER_CORRUPT" : "READER_PARSE") - ); - } -} -function loadPdfTaskWithTimeout(pdfjs, options, url) { - assertReaderActive(); - const task = pdfjs.getDocument(options(url)); - return new Promise((resolve, reject) => { - let timeout = 0, - destroyed = false; - const destroy = () => { - if (destroyed) return; - destroyed = true; - clearTimeout(timeout); - try { - task.destroy()?.catch?.(() => {}); - } catch (_) {} - reject(readerAbortError()); - }; - const untrack = trackReaderResource(destroy); - timeout = setTimeout(() => { - reject(new Error("reader PDF timeout")); - untrack(); - destroy(); - }, READER_PROXY_TIMEOUT_MS); - task.promise.then( - (document) => { - clearTimeout(timeout); - if (readerAbortController.signal.aborted) { - destroy(); - return; - } - resolve(document); - }, - (error) => { - clearTimeout(timeout); - reject(error); - untrack(); - destroy(); - } - ); - }); -} -function loadPdfWithTimeout(pdfjs, options, originalUrl = sourceUrl, proxyUrl = contentUrl) { - return retryReaderProxy(() => loadPdfTaskWithTimeout(pdfjs, options, proxyUrl)).catch((error) => { - assertReaderActive(); - if (error && error.name === "AbortError") throw error; - if (retryableReaderProxyError(error)) throw error; - return loadPdfTaskWithTimeout(pdfjs, options, originalUrl); - }); -} -function preloadPdfEngine() { - if (!pdfEnginePreload) pdfEnginePreload = loadPdfEngine().then(preparePdfWorker); - return pdfEnginePreload; -} -async function preparePdfWorker(pdfjs) { - for (let attempt = 0; attempt < 3; attempt++) { - assertReaderActive(); - if (attempt) await waitForReader(attempt * 500); - try { - return await preparePdfWorkerAttempt(pdfjs, attempt); - } catch (error) { - assertReaderActive(); - if (error?.code !== "READER_ENGINE_NETWORK" || attempt === 2) throw error; - } - } -} -async function preparePdfWorkerAttempt(pdfjs, attempt) { - assertReaderActive(); - if (capability.mode !== "pdf" && !pdfPrimarySource()) return pdfjs; - pdfjs.GlobalWorkerOptions.workerSrc = attempt - ? `${PDFJS_WORKER_URL}?reader-worker-retry=${attempt}` : PDFJS_WORKER_URL; - const worker = new pdfjs.PDFWorker(); - pdfWorker = worker; - let timer, destroyed = false; - const destroy = () => { - if (destroyed) return; - destroyed = true; - clearTimeout(timer); - worker.destroy(); - if (pdfWorker === worker) { - pdfWorker = null; - releasePdfWorker = null; - } - }; - releasePdfWorker = destroy; - const untrack = trackReaderResource(destroy); - try { - await awaitReader(Promise.race([ - worker.promise, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error("PDF worker timeout")), 20000); - }) - ])); - assertReaderActive(); - return pdfjs; - } catch (error) { - untrack(); - destroy(); - assertReaderActive(); - throw Object.assign(new Error("PDF 阅读组件下载失败,请检查网络后重试加载,或下载原文件。"), - { code: "READER_ENGINE_NETWORK", cause: error }); - } finally { - clearTimeout(timer); - } -} -async function loadPdfEngine() { - for (let attempt = 0; attempt < 3; attempt++) { - assertReaderActive(); - if (attempt) await waitForReader(attempt * 500); - assertReaderActive(); - // Browsers can retain a rejected import for the life of this document. - // Use two bounded retry URLs for the same pinned bytes, never a random URL. - const url = attempt ? `${PDFJS_URL}?reader-module-retry=${attempt}` : PDFJS_URL; - let timer; - try { - const pdfjs = await awaitReader( - Promise.race([ - import(url), - new Promise((_, reject) => { - timer = setTimeout( - () => reject(new DOMException("PDF engine timeout", "TimeoutError")), - 20000 - ); - }) - ]) - ); - assertReaderActive(); - return pdfjs; - } catch (error) { - assertReaderActive(); - const networkFailure = - error?.name === "TimeoutError" || - /failed to fetch dynamically imported module|error loading dynamically imported module|importing a module script failed/i.test( - error?.message || "" - ); - if (!networkFailure) throw error; - if (attempt === 2) - throw Object.assign( - new Error("PDF 阅读组件下载失败,请检查网络后重试加载,或下载原文件。"), - { code: "READER_ENGINE_NETWORK", cause: error } - ); - } finally { - clearTimeout(timer); - } - } -} -function loadPdfDocument(target = sourceUrl) { - return measurePdf(preloadPdfEngine(), "engine").then((pdfjs) => { - assertReaderActive(); - if (!pdfFetchPolicy) { - pdfFetchPolicy = createPdfFetchPolicy(); - trackReaderResource(() => { pdfFetchPolicy?.dispose(); pdfFetchPolicy = null; }); - } - pdfFetchPolicy.add([target, readerContentUrl(target)]); - pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL; - const options = (url) => ({ - url, - worker: pdfWorker, - // Bound demand reads for original and converted PDFs alike. Streaming - // otherwise keeps the full download running alongside these ranges. - rangeChunkSize: 262144, - disableAutoFetch: true, - disableStream: true, - wasmUrl: PDFJS_WASM_URL, - cMapUrl: PDFJS_CMAP_URL, - cMapPacked: true, - standardFontDataUrl: PDFJS_STANDARD_FONT_URL, - withCredentials: false - }); - return measurePdf(loadPdfWithTimeout(pdfjs, options, target, readerContentUrl(target)), "document"); - }); -} -function prepareReaderDocument(engineTasks) { - const engines = Promise.all(engineTasks); - const response = Promise.race([fetchReaderResponse(), engines.then(() => new Promise(() => {}))]); - // Rendering owns both promises; early rejection must not become unhandled. - response.catch(() => {}); - engines.catch(() => {}); - return {response, engines}; -} -function readPreparedBytes(response, engines, limit = VoiceOfMLReaderSecurity.LIMITS.documentBytes) { - return Promise.all([Promise.resolve(response).then(response => { - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return VoiceOfMLReaderSecurity.readBytes(response,limit); - }), engines]).then(([bytes]) => bytes); -} -function loadTextDocument() { - return fetchReaderResponse().then((response) => ({ response })); -} -function loadMarkdownDocument() { - return prepareReaderDocument([loadScript(MARKED_URL), loadScript(PURIFY_URL)]); -} -function loadHtmlDocument() { - const {response, engines} = prepareReaderDocument([loadScript(PURIFY_URL)]); - return {response, engine:engines}; -} -function loadDocxDocument() { - return prepareReaderDocument([loadScript(JSZIP_URL), loadScript(DOCX_PREVIEW_URL)]); -} -function loadMediaDocument() { - return Promise.resolve(null); -} -function waitReaderImage(image, target, timeoutMs, signal) { - assertReaderActive(); - return new Promise((resolve, reject) => { - let settled = false, timeout = 0, untrack = () => {}; - const abort = () => finish(readerAbortError()); - const finish = (error) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - untrack(); - signal?.removeEventListener("abort", abort); - image.onload = image.onerror = null; - if (error) { - image.removeAttribute("src"); - reject(error); - } else resolve(image); - }; - untrack = trackReaderResource(() => finish(readerAbortError())); - signal?.addEventListener("abort", abort, { once: true }); - if (signal?.aborted) abort(); - if (settled) return; - timeout = setTimeout(() => finish(new DOMException("Reader image stalled", "TimeoutError")), timeoutMs); - image.onload = () => finish(); - image.onerror = () => finish(new Error("image load failed")); - if (image.src !== new URL(target, location.href).href) image.src = target; - if (image.complete && image.naturalWidth) finish(); - }); -} -async function loadReaderImage(image, target, signal, deadline = performance.now() + READER_PROXY_TIMEOUT_MS) { - const remaining = () => Math.max(1, deadline - performance.now()); - const network = /^https?:$/.test(new URL(target, location.href).protocol); - try { - return await waitReaderImage(image, target, Math.min(network ? 15000 : READER_PROXY_TIMEOUT_MS, remaining()), signal); - } catch (error) { - assertReaderActive(); - signal?.throwIfAborted(); - if (!network || error?.name !== "TimeoutError" || !navigator.onLine) throw error; - } - const response = await fetchWithReaderTimeout(target, remaining(), - { signal, priority: image.fetchPriority || "auto" }); - if (!response.ok) { - await response.body?.cancel(); - throw Object.assign(new Error(`HTTP ${response.status}`), {status: response.status}); - } - const bytes = await VoiceOfMLReaderSecurity.readBytes(response); - assertReaderActive(); - signal?.throwIfAborted(); - const blobUrl = URL.createObjectURL(new Blob([bytes], {type: response.headers.get("Content-Type") || ""})); - try { - return await waitReaderImage(image, blobUrl, Math.min(20000, remaining()), signal); - } finally { - URL.revokeObjectURL(blobUrl); - } -} -async function loadImageDocument(target = sourceUrl) { - const deadline = performance.now() + READER_PROXY_TIMEOUT_MS; - const image = new Image(); - image.className = "reader-image"; - image.alt = documentState.title; - image.decoding = "async"; - try { - return await loadReaderImage(image, readerContentUrl(target), readerAbortController.signal, deadline); - } catch (error) { - assertReaderActive(); - if (error?.code === "READER_RESOURCE_LIMIT") throw error; - return loadReaderImage(image, target, readerAbortController.signal, deadline); - } -} -function loadChapterManifestDocument() { - const target = chapterManifestUrl || sourceUrl, early = window.__VOICE_READER_CHAPTER_PRELOAD__; - if (early?.manifest && early.manifestUrl === new URL(target, location.href).href) { - const pending = early.manifest; - early.manifest = null; - return pending.then(response => response.ok ? response : fetchReaderUrl(target), () => fetchReaderUrl(target)); - } - return fetchReaderUrl(target); -} // Foliate keeps a source-to-rendered text mapping because CFI addresses the // original document, before sanitization and insertion into a shadow root. -function cloneFoliateDocument(source) { - const doc = source.cloneNode(true), - original = source.createTreeWalker(source, NodeFilter.SHOW_TEXT), - cloned = doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT); - const sourceNodes = new WeakMap(); - while (original.nextNode() && cloned.nextNode()) - sourceNodes.set(cloned.currentNode, original.currentNode); - return { doc: sanitizeEpubDocument(doc), sourceNodes }; -} -function foliateSearchAnchor(source, sourceNodes, body, renderedBody) { - const sourceWalker = body.ownerDocument.createTreeWalker(body, NodeFilter.SHOW_TEXT); - const pairs = []; - let offset = 0; - while (sourceWalker.nextNode()) { - const node = sourceWalker.currentNode; - const original = sourceNodes.get(node); - if (original) pairs.push({ original, offset }); - offset += node.length; - } - return (anchor) => { - const range = anchor(source); - if (!range || typeof range.intersectsNode !== "function") return null; - const intervals = []; - for (const { original, offset } of pairs) { - if (!range.intersectsNode(original)) continue; - const start = range.startContainer === original ? range.startOffset : 0; - const end = range.endContainer === original ? range.endOffset : original.length; - if (end > start) intervals.push({ start: offset + start, end: offset + end }); - } - const walker = document.createTreeWalker(renderedBody, NodeFilter.SHOW_TEXT), - selected = []; - let offset = 0; - while (walker.nextNode()) { - const node = walker.currentNode, - end = offset + node.length; - for (const interval of intervals) - if (interval.start < end && interval.end > offset) - selected.push({ - node, - start: Math.max(0, interval.start - offset), - end: Math.min(node.length, interval.end - offset) - }); - offset = end; - } - return selected; - }; -} -async function settleReaderImages(root, timeoutMs = 2500) { - assertReaderActive(); - const images = [...root.querySelectorAll("img[src],img[srcset]")]; - if (!images.length) return; - let timeout = 0, active = true; - const cleanups = new Set(); - const decoded = Promise.allSettled( - images.map(async (image) => { - // Sections are prepared off-DOM; lazy images cannot enter a viewport yet. - image.loading = "eager"; - image.decoding = "async"; - if (!image.complete) await new Promise((resolve) => { - const finish = () => { - image.removeEventListener("load", finish); - image.removeEventListener("error", finish); - cleanups.delete(finish); - resolve(); - }; - cleanups.add(finish); - image.addEventListener("load", finish, { once: true }); - image.addEventListener("error", finish, { once: true }); - if (image.complete) finish(); - }); - if (active) { - try { await image.decode?.(); } catch (_) {} - } - }) - ); - try { - await awaitReader( - Promise.race([ - decoded, - new Promise((resolve) => { - timeout = setTimeout(resolve, timeoutMs); - }) - ]) - ); - } finally { - active = false; - clearTimeout(timeout); - for (const cleanup of cleanups) cleanup(); - } -} -function rewriteFoliateResources(doc, section) { - const safeEmbeddedValue = (value) => - /^(?:blob:|data:image\/(?:gif|png|jpe?g|webp);)/i.test(String(value || "").trim()); - const archiveResourceUrl = (value) => { - const raw = String(value || "").trim(); - if (!raw || raw.startsWith("#") || raw.includes("#") || safeEmbeddedValue(raw)) - return safeEmbeddedValue(raw) ? raw : null; - if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(raw)) return null; - try { - const resolved = String( - (section.resolveResourceHref - ? section.resolveResourceHref(raw) - : section.resolveHref?.(raw)) || "" - ); - if ( - !resolved || - /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(resolved) || - /[\\\x00]/.test(resolved) || - resolved.split("/").some((part) => !part || part === "." || part === "..") - ) - return null; - return `/api/reader-resource?book=${encodeURIComponent(sourceUrl)}&path=${encodeURIComponent(resolved)}`; - } catch (_) { - return null; - } - }; - for (const element of doc.querySelectorAll( - "img[src],source[src],audio[src],video[src],video[poster]" - )) { - for (const name of ["src", "poster"]) - if (element.hasAttribute(name)) { - const safe = archiveResourceUrl(element.getAttribute(name)); - if (safe) element.setAttribute(name, safe); - else element.removeAttribute(name); - } - } - for (const element of doc.querySelectorAll("[srcset]")) { - const value = element.getAttribute("srcset") || ""; - const candidates = value - .split(",") - .map((part) => { - const [raw, ...descriptor] = part.trim().split(/\s+/); - const safe = archiveResourceUrl(raw); - return safe ? [safe, ...descriptor].join(" ") : null; - }) - .filter(Boolean); - if (candidates.length) element.setAttribute("srcset", candidates.join(", ")); - else element.removeAttribute("srcset"); - } - for (const image of doc.querySelectorAll("image")) { - const value = - image.getAttributeNS("http://www.w3.org/1999/xlink", "href") || - image.getAttribute("xlink:href") || - image.getAttribute("href"); - const safe = archiveResourceUrl(value); - if (safe) { - image.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", safe); - image.setAttribute("href", safe); - } else { - image.removeAttributeNS("http://www.w3.org/1999/xlink", "href"); - image.removeAttribute("xlink:href"); - image.removeAttribute("href"); - } - } - for (const link of doc.querySelectorAll("a[href]")) { - const href = link.getAttribute("href") || ""; - if (/\.(?:gif|png|jpe?g|webp)$/i.test(href)) { - const resource = archiveResourceUrl(href); - if (resource) { - link.setAttribute("href", resource); - link.target = "_blank"; - link.rel = "noopener noreferrer"; - } - } - if (/^(?:https?:|mailto:|tel:)/i.test(href)) { - link.target = "_blank"; - link.rel = "noopener noreferrer"; - } - } -} -async function createFoliateSection(section, index) { - const signal = arguments[2] || readerAbortController.signal; - const priority = arguments[3] || "high"; - const source = await awaitReader(section.createDocument(), signal); - const { doc, sourceNodes } = cloneFoliateDocument(source); - assertReaderActive(); - rewriteFoliateResources(doc, section); - const styles = [...doc.querySelectorAll("style,link")].map((node) => node.cloneNode(true)); - for (const node of doc.querySelectorAll("style,link")) node.remove(); - const article = document.createElement("article"); - article.dataset.section = String(index); - const shadow = article.attachShadow({ mode: "open" }), - baseStyle = document.createElement("style"), - sectionBody = document.createElement("div"); - // Chapter styles live in a shadow root, so document-level preferences cannot reach them. - baseStyle.textContent = ` - :host { display: block; color: inherit; } - * { box-sizing: border-box; } - .reader-section-body { display: block; color: inherit; line-height: inherit; } - .reader-section-body > :first-child { margin-top: 0 !important; } - :host(.reader-document-dark) .reader-section-body, - :host(.reader-document-dark) .reader-section-body :where(:not(svg, svg *, img, canvas, video, audio, math, math *)) { - color: inherit !important; - background-color: transparent !important; - border-color: #4a5056 !important; - text-shadow: none !important; - } - a { color: var(--reader-book-link) !important; } - :host(.reader-document-dark) .reader-section-body a { color: var(--reader-book-link) !important; } - img, svg, video { max-width: 100%; height: auto; } - mark.full-search-highlight { background: #ffd54f; color: #111; } - :host(.reader-document-dark) .reader-section-body mark.full-search-highlight { background: #ffd54f !important; color: #111 !important; } - @media (prefers-contrast: more) { - .reader-section-body a[href] { text-decoration: underline !important; } - .reader-section-body mark.full-search-highlight { outline: 2px solid currentColor; } - } - `; - sectionBody.className = "reader-section-body"; - const body = epubContentBody(doc); - // Import the sanitized nodes directly. Serializing and reparsing legacy - // nested anchors lets HTML's adoption rules reorder footnotes and citations. - if (body) - sectionBody.append(...[...body.childNodes].map((node) => document.importNode(node, true))); - article._resolveSearchAnchor = foliateSearchAnchor(source, sourceNodes, body, sectionBody); - shadow.append(baseStyle, ...styles, sectionBody); - for (const image of sectionBody.querySelectorAll("img")) { - image.loading = priority === "high" ? "eager" : "lazy"; - image.fetchPriority = priority; - image.decoding = "async"; - } - signal.throwIfAborted(); - assertReaderActive(); - article.classList.toggle("reader-document-dark", readerTheme === "dark"); - return article; -} -function setupFoliateWindow(stream, sections) { - let focus = initialReaderTargetSection ?? 0, targetPending = false; - stream.style.overflowAnchor = "none"; - const existing = index => stream.querySelector(`article[data-section="${index}"]:not(.foliate-section-placeholder)`); - const ensurePlaceholder = index => { - if (!sections[index] || stream.querySelector(`article[data-section="${index}"]`)) return; - const placeholder = document.createElement("article"); - placeholder.className = "foliate-section-placeholder"; - placeholder.dataset.section = String(index); - placeholder.style.setProperty("--foliate-placeholder-height", `${Math.max(400, viewport.clientHeight)}px`); - placeholder.setAttribute("aria-label", `加载第 ${index + 1} 节`); - const next = [...stream.children].find(node => Number(node.dataset.section) > index); - foliateScrollAnchors.preserve(() => stream.insertBefore(placeholder, next || null)); - observer.observe(placeholder); - }; - const warm = () => { - const indices = []; - for (const offset of [-1, 1, -2, 2, 3, 4, 5, 6]) { - const index = focus + offset; - if (sections[index] && !existing(index)) indices.push(index); - } - scheduler.prefetch(document.hidden ? [] : indices); - }; - const refresh = () => { - if (readerAbortController.signal.aborted) return; - const marker = viewport.getBoundingClientRect().top + 80; - const rows = [...stream.children]; - const current = rows.find(node => { - const rect = node.getBoundingClientRect(); - return rect.top <= marker && rect.bottom > marker; - }) || rows.find(node => node.getBoundingClientRect().top > marker); - const index = current ? Number(current.dataset.section) : focus; - if (targetPending && index !== focus && !foliateScrollAnchors.scrolling) return; - targetPending = false; - focus = index; - if (current?.classList.contains("foliate-section-placeholder")) scheduler.load(index).catch(() => {}); - warm(); - foliateScrollAnchors.whenIdle(trimFoliateSections); - }; - const observer = new IntersectionObserver(scheduleFoliateWindowRefresh, - { root: viewport, rootMargin: `${FOLIATE_PREFETCH_MARGIN}px` }); - foliateSectionObserver = observer; - const commitSection = (index, article) => - foliateScrollAnchors.preserve(() => { - const placeholder = stream.querySelector( - `article.foliate-section-placeholder[data-section="${index}"]` - ), - next = [...stream.querySelectorAll("article[data-section]")].find( - (item) => Number(item.dataset.section) > index - ); - if (placeholder) { - observer.unobserve(placeholder); - placeholder.replaceWith(article); - } else stream.insertBefore(article, next || null); - observer.observe(article); - foliateScrollAnchors.observe(article); - ensurePlaceholder(index - 1); - ensurePlaceholder(index + 1); - return article; - }); - const scheduler = readerRuntime.track(VoiceOfMLReaderChapters.createChapterScheduler({ - create: async (index, signal, priority) => { - const article = await createFoliateSection(sections[index], index, signal, priority); - signal.throwIfAborted(); - if (existing(index)) return existing(index); - return commitSection(index, article); - } - })); - foliateChapterRepository = VoiceOfMLReaderChapters.createChapterRepository({ - count: sections.length, find: existing, create: index => scheduler.load(index) - }); - foliateSectionVirtualizer = VoiceOfMLReaderVirtual.createSectionVirtualizer({ - limit: FOLIATE_LOADED_SECTION_LIMIT, - getLoaded: () => [ - ...stream.querySelectorAll("article[data-section]:not(.foliate-section-placeholder)") - ], - getIndex: (article) => Number(article.dataset.section), - getHeight: (article) => article.getBoundingClientRect().height, - canVirtualize: (article) => { - const rect = article.getBoundingClientRect(), - viewportRect = viewport.getBoundingClientRect(); - return ( - rect.bottom < viewportRect.top - FOLIATE_PREFETCH_MARGIN || - rect.top > viewportRect.bottom + FOLIATE_PREFETCH_MARGIN - ) && !article.contains(document.activeElement) && !article.shadowRoot?.activeElement && - !foliateSectionRoot(article).querySelector("mark.full-search-highlight") && - ![window.getSelection(), article.shadowRoot?.getSelection?.()].some(selection => - selection && !selection.isCollapsed && (selection.containsNode(article, true) || - selection.anchorNode?.getRootNode() === article.shadowRoot)); - }, - virtualize: (article, index, height) => { - const placeholder = document.createElement("article"); - placeholder.className = "foliate-section-placeholder"; - placeholder.dataset.section = String(index); - placeholder.style.setProperty("--foliate-placeholder-height", `${height}px`); - placeholder.setAttribute("aria-hidden", "true"); - observer.unobserve(article); - foliateScrollAnchors.unobserve(article); - for (const image of foliateSectionRoot(article).querySelectorAll("img")) { - image.removeAttribute("src"); - image.removeAttribute("srcset"); - } - article._resolveSearchAnchor = null; - article.replaceWith(placeholder); - observer.observe(placeholder); - return placeholder; - }, - release: (index) => foliateChapterRepository.release(index), - preserve: (change) => foliateScrollAnchors.preserve(change) - }); - foliateSectionLoader = index => { - focus = index; - targetPending = true; - const task = existing(index) ? Promise.resolve(existing(index)) : scheduler.load(index, true, true); - warm(); - return task; - }; - foliateSectionLoader.refresh = refresh; - foliateSectionSettler = () => waitForReader(0, true); - const scroll = scheduleFoliateWindowRefresh; - viewport.addEventListener("scroll", scroll, { passive: true }); - document.addEventListener("visibilitychange", scroll); - trackReaderResource(() => { - viewport.removeEventListener("scroll", scroll); - document.removeEventListener("visibilitychange", scroll); - }); -} -async function foliateTocEntries(view, sections) { - assertReaderActive(); - const entries = []; - const append = (items, depth = 0) => { - for (const item of items || []) { - const href = item?.href ? String(item.href) : ""; - if (href || item?.label) - entries.push({ - label: item.label || "未命名章节", - href, - depth, - group: !href, - activate: () => view.goTo(href) - }); - append(item.subitems, depth + 1); - } - }; - append(view.book.toc); - const sectionIndices = new Map(); - for (const [index, section] of sections.entries()) - if (!sectionIndices.has(section)) sectionIndices.set(section, index); - let next = 0, yieldDeadline = performance.now() + 8, yieldTask = null; - const resolveEntries = async () => { - while (next < entries.length) { - assertReaderActive(); - if (yieldTask || performance.now() >= yieldDeadline) { - yieldTask ||= waitForReader().finally(() => { - yieldTask = null; - yieldDeadline = performance.now() + 8; - }); - await yieldTask; - continue; - } - const entry = entries[next++]; - if (entry.group) { - entry.sectionIndex = -1; - entry.fragment = ""; - continue; - } - try { - const target = await view.book.resolveHref(entry.href), - section = target && view.book.sections[target.index]; - assertReaderActive(); - entry.sectionIndex = section ? sectionIndices.get(section) ?? -1 : -1; - entry.anchor = typeof target?.anchor === "function" ? target.anchor : null; - const fragment = entry.href.split("#")[1] || ""; - entry.fragment = fragment ? decodeURIComponent(fragment) : ""; - } catch (_) { - assertReaderActive(); - entry.sectionIndex = -1; - entry.fragment = ""; - } - } - }; - await awaitReader(Promise.all(Array.from({ length: Math.min(8, entries.length) }, resolveEntries))); - assertReaderActive(); - return entries; -} -async function renderFoliate() { - const prepared = prepareReaderDocument([import("/static/foliate-reader/view.js")]); - const [response, bytes] = await Promise.all([prepared.response,readPreparedBytes( - prepared.response,prepared.engines,VoiceOfMLReaderSecurity.LIMITS.archiveCompressedBytes)]); - assertReaderActive(); - if (VoiceOfMLReaderSecurity.isZipContainer(extension, bytes)) - VoiceOfMLReaderSecurity.inspectZip(bytes); - const view = document.createElement("foliate-view"), - stream = document.createElement("div"); - view.className = "foliate-reader-view"; - stream.className = "foliate-continuous"; - content.replaceChildren(loadingIndicator, view, stream); - epubRendition = view; - await view.open( - new File([bytes], new URL(sourceUrl).pathname.split("/").pop() || "book.epub", { - type: response.headers.get("content-type") || "application/epub+zip" - }) - ); - assertReaderActive(); - epubBook = view.book; - view.style.display = "none"; - const sections = view.book.sections.filter((section) => section.linear !== "no"); - await getInitialReaderRestoration(); - const savedSection = Number(readerRestorationEntry?.foliateSection); - const initialSection = Number.isInteger(savedSection) - ? Math.max(0, Math.min(sections.length - 1, savedSection)) - : 0; - initialReaderTargetSection = readerRestorationEntry?.foliateSection == null ? null : initialSection; - setupFoliateWindow(stream, sections); - if (sections[initialSection]) await foliateChapterRepository.load(initialSection); - if (initialSection > 0) await foliateChapterRepository.load(initialSection - 1); - assertReaderActive(); - // Resolving every TOC href can be expensive for large books. The first - // chapter is already visible, so finish the opening path before enriching - // the navigation panel in the background. - foliateTocEntries(view, sections) - .then((entries) => { - if (entries.length && !readerAbortController.signal.aborted) setToc(entries); - }) - .catch(() => {}); - scheduleFoliateScrollSync(); - markReaderContentReady(); - status.textContent = "EPUB"; -} + // Reading progress controls and undo. const progressTools = document.createElement("div"); -progressTools.className = "reader-progress-tools"; -progressTools.innerHTML = - '
0.0%
'; -document.querySelector("#history-panel").appendChild(progressTools); +readerBindings.mountProgressControls(); const readingProgressSummary = document.createElement("div"); -readingProgressSummary.className = "reader-progress-summary"; -readingProgressSummary.innerHTML = - '阅读状态0.0%'; -progressTools.prepend(readingProgressSummary); -readingProgressSummary - .querySelector(".reader-progress-bookmark") - .addEventListener("click", (event) => openBookmarkPopover(event.currentTarget)); -function updateReadingProgressSummary() { - const noToc = !navigationState.tocEntries.length && !mediaElement; - readingProgressSummary.hidden = !noToc; - if (noToc) - readingProgressSummary.querySelector(".reader-progress-summary-value").textContent = - `${readerProgressPercent().toFixed(1)}%`; -} -updateReadingProgressSummary(); -function syncTocTab() { - const tab = document.querySelector("#toc-tab"); - if (!tab) return; - tab.hidden = !navigationState.tocEntries.length; - if (navigationState.tocEntries.length && navigationState.currentChapterIndex < 0) - updateNavigationState({ currentChapterIndex: navigationState.tocEntries.findIndex((entry) => !entry.group) }); -} -syncTocTab(); +readerBindings.mountProgressSummary(); const progressRange = progressTools.querySelector(".reader-progress-range"), progressPercent = progressTools.querySelector(".reader-progress-percent"), progressUndo = progressTools.querySelector(".reader-progress-undo"), @@ -6562,188 +967,8 @@ let previousProgressState = null, epubSeekFrame = 0, epubSeekTarget = null, epubSeekPromise = Promise.resolve(); -function updateProgressTools() { - if (progressSeeking) return; - const percent = readerProgressPercent(); - progressRange.value = String(percent); - progressPercent.textContent = `${percent.toFixed(1)}%`; - const hasChapters = navigationState.tocEntries.some((entry) => !entry.group), - hasPreviousChapter = adjacentTocIndex(-1) >= 0, - hasNextChapter = adjacentTocIndex(1) >= 0; - chapterPrev.hidden = !hasChapters; - chapterNext.hidden = !hasChapters; - chapterPrev.disabled = !hasPreviousChapter; - chapterNext.disabled = !hasNextChapter; - progressUndo.hidden = !previousProgressState; - updateReadingProgressSummary(); - placeReadingProgress(); -} -function captureProgressState() { - return foliateContinuous - ? captureFoliateBookmarkPosition() - : capability.mode === "epub-chapters" - ? captureChapterPosition() - : mediaElement - ? { mediaTime: mediaElement.currentTime } - : htmlFrame && htmlFrame.contentWindow - ? { htmlScrollTop: htmlFrame.contentWindow.scrollY } - : { scrollTop: viewport.scrollTop }; -} -async function restoreProgressState(state, generation = beginReaderNavigation()) { - if (!state || !isReaderGenerationCurrent("navigation", generation)) return false; - if (foliateContinuous && Number.isInteger(state.foliateSection)) - return restoreFoliateBookmarkPosition(state, generation); - if (capability.mode === "epub-chapters" && Number.isInteger(state.chapterIndex)) - return restoreChapterPosition(state, generation); - try { - if (mediaElement && Number.isFinite(state.mediaTime)) { - const media = mediaElement; - if (!media.readyState) - await new Promise((resolve, reject) => { - const finish = (error) => { - media.removeEventListener("loadedmetadata", loaded); - media.removeEventListener("error", failed); - untrack(); - if (error) reject(error); - else resolve(); - }; - const loaded = () => finish(), - failed = () => { - if (!media._readerSourceRetry) finish(new Error("Media metadata unavailable")); - }; - const untrack = trackReaderResource(() => finish(readerAbortError())); - media.addEventListener("loadedmetadata", loaded, { once: true }); - media.addEventListener("error", failed); - }); - if (!isReaderGenerationCurrent("navigation", generation)) return false; - media.currentTime = state.mediaTime; - } else if (htmlFrame && htmlFrame.contentWindow && Number.isFinite(state.htmlScrollTop)) - htmlFrame.contentWindow.scrollTo(0, state.htmlScrollTop); - else if (Number.isFinite(state.scrollTop)) { - if (capability.mode === "text") { - let measured = 0, batch = 0, started = performance.now(); - for (const block of content.querySelectorAll(".reader-text-block")) { - if (!isReaderGenerationCurrent("navigation", generation)) return false; - block.style.contentVisibility = "visible"; - const height = block.getBoundingClientRect().height; - block.style.containIntrinsicBlockSize = `auto ${height}px`; - block.style.contentVisibility = "auto"; - measured += height; - if (measured > state.scrollTop + viewport.clientHeight) break; - if (++batch >= 8 || performance.now() - started >= 8) { - await waitForReader(); - batch = 0; - started = performance.now(); - } - } - } - if (!isReaderGenerationCurrent("navigation", generation)) return false; - viewport.scrollTop = state.scrollTop; - } - updateProgressTools(); - scheduleSave(); - return true; - } catch (error) { - reportNavigationError(error, generation); - return false; - } -} -function flushEpubSeek() { - if (epubSeekFrame) { - cancelAnimationFrame(epubSeekFrame); - epubSeekFrame = 0; - epubSeekPromise = seekFoliateProgress(epubSeekTarget.percent, epubSeekTarget.generation); - } - return epubSeekPromise; -} -function seekProgress(value, preserve = false) { - if (!preserve || !previousProgressState) previousProgressState = captureProgressState(); - const generation = beginReaderNavigation(), - percent = Math.max(0, Math.min(100, Number(value))); - if (!isReaderGenerationCurrent("navigation", generation)) return; - if (mediaElement && mediaElement.duration) - mediaElement.currentTime = (mediaElement.duration * percent) / 100; - else if (foliateContinuous) { - epubSeekTarget = { percent, generation }; - epubSeekFrame = requestAnimationFrame(() => { - epubSeekFrame = 0; - epubSeekPromise = seekFoliateProgress(percent, generation); - }); - } else if (capability.mode === "epub-chapters" && chapterManifestLoader?.count) { - const position = Math.min(0.999999, percent / 100) * chapterManifestLoader.count; - epubSeekPromise = (async () => { - const index = Math.floor(position) + 1; - const article = await chapterManifestLoader(index); - if (!article?.isConnected || !isReaderGenerationCurrent("navigation", generation)) return false; - return restoreChapterPosition({chapterIndex:index,chapterManifest:chapterManifestUrl || sourceUrl, - chapterOffset:article.getBoundingClientRect().height * (position - Math.floor(position))}, generation); - })().catch(error => { reportNavigationError(error, generation); return false; }); - } else if (htmlFrame && htmlFrame.contentWindow) - htmlFrame.contentWindow.scrollTo( - 0, - (Math.max( - 0, - htmlFrame.contentDocument.documentElement.scrollHeight - htmlFrame.contentWindow.innerHeight - ) * - percent) / - 100 - ); - else - viewport.scrollTop = - (Math.max(0, viewport.scrollHeight - viewport.clientHeight) * percent) / 100; - updateProgressTools(); - scheduleSave(); -} -async function activateChapter(index) { - if ( - index < 0 || - index >= navigationState.tocEntries.length || - index === navigationState.currentChapterIndex - ) - return; - try { - await navigateTocEntry(index); - } finally { - updateProgressTools(); - } -} -function adjacentTocIndex(direction) { - for (let index = navigationState.currentChapterIndex + direction; - index >= 0 && index < navigationState.tocEntries.length; index += direction) { - if (!navigationState.tocEntries[index].group) return index; - } - return -1; -} -progressRange.addEventListener("pointerdown", () => { - beginReaderNavigation(); - progressSeeking = true; - previousProgressState = captureProgressState(); -}); -progressRange.addEventListener("input", () => { - progressPercent.textContent = `${Number(progressRange.value).toFixed(1)}%`; - seekProgress(progressRange.value, true); -}); -for (const type of ["pointerup", "pointercancel"]) - progressRange.addEventListener(type, () => { - progressSeeking = false; - const generation = readerRuntime.currentGeneration("navigation"); - flushEpubSeek().then(() => { - if (isReaderGenerationCurrent("navigation", generation)) updateProgressTools(); - }); - }); -progressUndo.addEventListener("click", () => { - const state = previousProgressState; - previousProgressState = null; - progressSeeking = false; - restoreProgressState(state); - updateProgressTools(); -}); -chapterPrev.addEventListener("click", () => - activateChapter(adjacentTocIndex(-1)) -); -chapterNext.addEventListener("click", () => - activateChapter(adjacentTocIndex(1)) -); + +readerBindings.bindProgressActions(); const domSearch = createReaderDomSearch({ content, runtime: readerRuntime, isCurrent: isReaderGenerationCurrent, yieldTask: waitForReader, getTocEntries: () => navigationState.tocEntries, @@ -6758,7 +983,7 @@ const domSearch = createReaderDomSearch({ } } }); -trackReaderResource(domSearch.dispose); +readerBindings.ownDomSearchController(); const { search: fullSearchDomMatches, clearMarks: clearFullSearchMarks, highlight: highlightTextParts @@ -6793,7 +1018,7 @@ const fullSearchController = createReaderSearchController({ focus: () => { readerPanelInteractionSerial++; } } }); -trackReaderResource(fullSearchController.dispose); +readerBindings.ownSearchViewController(); const fullSearchSession = fullSearchController.state; const { render: renderFullSearchResults, applyChapterPage: applyChapterSearchPage, @@ -6805,583 +1030,10 @@ const { } = fullSearchController.elements; // Format-specific search backends publish through the shared controller. -function chapterSearchModule() { - if (!chapterSearchModulePromise) - chapterSearchModulePromise = import("/static/reader-chapter-search.mjs").catch((error) => { - chapterSearchModulePromise = null; - throw error; - }); - return chapterSearchModulePromise; -} -async function searchChapterBook(query, generation) { - if (!chapterSearchConfiguration) throw new Error("本书暂无完整全文搜索索引"); - const module = await chapterSearchModule(); - if (!isReaderGenerationCurrent("search", generation)) return; - if (chapterSearchClient?.failed) { - chapterSearchClient.dispose(); - chapterSearchClient = null; - } - if (!chapterSearchClient) - chapterSearchClient = module.createChapterSearch(chapterSearchConfiguration); - const page = await chapterSearchClient.search(query, partial => { - if (!isReaderGenerationCurrent("search", generation)) return; - applyChapterSearchPage(partial); - renderFullSearchResults(); - fullSearchStatus.textContent = partial.total - ? `${partial.total} 个结果(正在搜索… ${partial.scanned} / ${partial.chapters} 章)` - : `正在搜索正文… ${partial.scanned} / ${partial.chapters} 章`; - }); - if (isReaderGenerationCurrent("search", generation)) applyChapterSearchPage(page); -} - -async function navigateChapterSearchResult(result, generation) { - const article = await chapterManifestLoader?.(result.chapterIndex); - if (!article || !isReaderGenerationCurrent("navigation", generation)) return false; - const module = await chapterSearchModule(); - if (!isReaderGenerationCurrent("navigation", generation)) return false; - clearFullSearchMarks(); - const mapping = module.chapterTextMap([article._chapterSearchHead, article]); - const match = result.snippet.text.slice( - result.snippet.matchStart, - result.snippet.matchStart + result.snippet.matchLength - ); - let start = result.start; - if (mapping.text.slice(start, start + result.length) !== match) { - // A browser may repair malformed source HTML differently from HTMLParser. - // Relocate only by a unique full excerpt; never guess between repeated hits. - const found = mapping.text.indexOf(result.snippet.text); - if (found < 0 || mapping.text.indexOf(result.snippet.text, found + 1) >= 0) - throw new Error("已加载章节,但未能准确定位这处文字"); - start = found + result.snippet.matchStart; - } - const parts = mapping.parts(start, start + result.length, article); - const headText = module.chapterTextMap([article._chapterSearchHead]).text; - if (start < headText.length) { - const heading = document.createElement("p"); - heading.className = "reader-chapter-search-heading"; - heading.textContent = headText; - article.prepend(heading); - parts.unshift({ node: heading.firstChild, start, end: Math.min(start + result.length, headText.length) }); - } - const [mark] = highlightTextParts(parts); - foliateScrollAnchors.invalidate(); - (mark || article).scrollIntoView({ block: "center" }); - foliateScrollAnchors.remember(); - const tocIndex = navigationState.tocEntries.findIndex( - (entry) => entry.chapterIndex === result.chapterIndex - ); - if (tocIndex >= 0) { - updateNavigationState({ currentChapterIndex: tocIndex }); - updateTocCurrentMark(); - } - updateProgressTools(); - scheduleSave(); - return true; -} - -async function fullSearchFoliateMatches(query, generation) { - if (!epubRendition?.search) return []; - const groups = [], firstPage = []; - // Cache only visited pages for this query; never retain every match in the book. - const cachedPages = new Map(); - let total = 0; - const toResult = (item, label, occurrence) => { - const excerpt = item.excerpt || { pre: "", match: query, post: "" }; - return { - location: label || "电子书位置", - cfi: item.cfi, - occurrence, - snippet: { - text: `${excerpt.pre}${excerpt.match}${excerpt.post}`, - matchStart: excerpt.pre.length, - matchLength: excerpt.match.length, - prefix: "", - suffix: "" - } - }; - }; - fullSearchSession.pageLoader = async (requestedOffset, pageGeneration = readerRuntime.currentGeneration("search")) => { - const pageSize = 50; - const offset = fullSearchPageOffset(total, requestedOffset, pageSize); - if (!offset) return { total, offset, pageSize, results: firstPage.slice() }; - const cached = cachedPages.get(offset); - if (cached) { - cachedPages.delete(offset); - cachedPages.set(offset, cached); - return cached; - } - const results = []; - let passed = 0; - for (const group of groups) { - if (passed + group.count <= offset) { passed += group.count; continue; } - const sectionOffset = Math.max(0, offset - passed); - const limit = Math.min(pageSize - results.length, group.count - sectionOffset); - for await (const batch of epubRendition.search({ - query, index: group.index, offset: sectionOffset, limit, lean: true, - current: () => isReaderGenerationCurrent("search", pageGeneration) - })) - for (const [index, item] of batch.subitems.entries()) - results.push(toResult(item, group.label, sectionOffset + index)); - passed += group.count; - if (results.length === pageSize) break; - } - const page = { total, offset, pageSize, results }; - if (isReaderGenerationCurrent("search", pageGeneration) && JSON.stringify(page).length <= 128 * 1024) { - cachedPages.set(offset, page); - if (cachedPages.size > 4) cachedPages.delete(cachedPages.keys().next().value); - } - return page; - }; - for await (const group of epubRendition.search({ - query, lean: true, limit: 50, - current: () => isReaderGenerationCurrent("search", generation) - })) { - if (!isReaderGenerationCurrent("search", generation)) return []; - if (group.count) groups.push({ index: group.index, count: group.count, label: group.label }); - for (const [index, item] of group.subitems.entries()) { - if (firstPage.length === 50) break; - firstPage.push(toResult(item, group.label, index)); - } - total += group.count; - fullSearchSession.page = { total, offset: 0, pageSize: 50, results: firstPage.slice() }; - updateSearchState({ results: fullSearchSession.page.results, index: -1 }); - renderFullSearchResults(); - fullSearchStatus.textContent = `${total} 个结果(正在搜索…)`; - await waitForReader(); - if (!isReaderGenerationCurrent("search", generation)) return []; - } - fullSearchSession.page = { total, offset: 0, pageSize: 50, results: firstPage.slice() }; - return firstPage; -} -async function navigateFoliateSearchResult(result, generation) { - const resolved = await epubRendition?.resolveNavigation?.(result.cfi); - if (!resolved || !isReaderGenerationCurrent("navigation", generation)) return false; - const section = epubBook?.sections?.[resolved.index], - sections = epubBook?.sections?.filter((item) => item.linear !== "no") || [], - visibleIndex = section ? sections.indexOf(section) : -1; - if (visibleIndex < 0 || !foliateSectionLoader) return false; - let node = await foliateSectionLoader(visibleIndex); - if (!isReaderGenerationCurrent("navigation", generation)) return false; - await waitForReader(0, true); - if (!isReaderGenerationCurrent("navigation", generation)) return false; - if (!node?.isConnected) node = await foliateSectionLoader(visibleIndex); - if (!node?.isConnected || !isReaderGenerationCurrent("navigation", generation)) return false; - clearFullSearchMarks(); - let target; - if (typeof resolved.anchor === "function" && node._resolveSearchAnchor) { - const parts = node._resolveSearchAnchor(resolved.anchor); - if (!parts?.length) return false; - [target] = highlightTextParts(parts); - } else { - // Some non-CFI engines expose only a section locator. - const matches = await fullSearchDomMatches( - foliateSectionRoot(node), - "电子书位置", - searchState.query, - readerRuntime.currentGeneration("search"), - false, - result.occurrence || 0, - generation - ); - target = matches[0]?.target; - } - if (!isReaderGenerationCurrent("navigation", generation) || !target) return false; - const targetRect = target.getBoundingClientRect(), - viewportRect = viewport.getBoundingClientRect(); - foliateScrollAnchors.invalidate(); - viewport.scrollTop = Math.max( - 0, - viewport.scrollTop + - targetRect.top - - viewportRect.top - - (viewport.clientHeight - targetRect.height) / 2 - ); - const tocIndex = navigationState.tocEntries.findIndex( - (entry) => entry.sectionIndex === visibleIndex - ); - if (tocIndex >= 0) { - updateNavigationState({ currentChapterIndex: tocIndex }); - updateTocCurrentMark(); - } - updateProgressTools(); - scheduleSave(); - foliateScrollAnchors.remember(); - return true; -} - -async function ensurePdfBookSearchClient(manifest) { - let textUrl = ""; - let partitioned = null; - if (manifest?.text_layer) { - pdfTextLayerRepository ||= createPdfV3Repository(manifest.source_sha256, manifest.page_count); - const index = await pdfTextLayerRepository.textIndex(manifest.text_layer); - manifest = { ...manifest, book_text: index.book_text }; - textUrl = v3ResourceUrl(index.book_text); - if (index.search_partitions) partitioned = {generation: index.generation, - partitions: index.search_partitions.map(part => ({start: part.start, end: part.end, - url: v3ResourceUrl(part.resource), bytes: part.resource.bytes, sha256: part.resource.sha256}))}; - } - if (!manifest?.book_text?.path) throw new Error("本书集中全文尚未生成"); - if (!pdfBookSearchModulePromise) - pdfBookSearchModulePromise = import("/static/reader-pdf-book-search.mjs").catch(error => { - pdfBookSearchModulePromise = null; - throw error; - }); - const module = await pdfBookSearchModulePromise; - assertReaderActive(); - if (pdfBookSearchClient?.failed) { - pdfBookSearchClient.dispose(); - pdfBookSearchClient = null; - } - if (!pdfBookSearchClient) - pdfBookSearchClient = module.createPdfBookSearch({ - url: textUrl || pdfOcrSource().assetUrl(manifest.book_text.path), - sha256: manifest.book_text.sha256, - bytes: manifest.book_text.bytes, - pageCount: documentState.pageCount, - sourceSha: manifest.source_sha256, - ...partitioned - }); - return pdfBookSearchClient; -} -async function prefetchPdfBookSearch() { - if (readerAbortController.signal.aborted || pdfBookSearchPrefetchPromise || !ocrManifestUrl || - !["pdf", "pdf-pages"].includes(capability.mode) || navigator.connection?.saveData) return; - const connection = navigator.connection; - if (["slow-2g", "2g"].includes(connection?.effectiveType)) return; - pdfBookSearchPrefetchPromise = (async () => { - const manifest = await loadPdfOcrManifest(); - const bytes = Number(manifest?.book_text?.bytes); - if (!Number.isSafeInteger(bytes) || bytes < 1 || bytes > PDF_BOOK_SEARCH_PREFETCH_MAX_BYTES) return; - const client = await ensurePdfBookSearchClient(manifest); - await client.prefetch(); - })().catch(() => {}); -} -function schedulePdfBookSearchPrefetch() { - if (pdfBookSearchPrefetchTimer || pdfBookSearchPrefetchPromise || !ocrManifestUrl || - !["pdf", "pdf-pages"].includes(capability.mode) || navigator.connection?.saveData) return; - pdfBookSearchPrefetchTimer = setTimeout(() => { - pdfBookSearchPrefetchTimer = 0; - const idle = window.requestIdleCallback || ((callback) => setTimeout(callback, 0)); - idle(() => prefetchPdfBookSearch(), { timeout: 1500 }); - }, PDF_BOOK_SEARCH_PREFETCH_DELAY); -} -async function searchConcentratedPdf(query, generation) { - const manifest = await loadPdfOcrManifest(); - const client = await ensurePdfBookSearchClient(manifest); - let firstPageResults = []; - const attach = (page) => { - const results = page.results.map((hit, position) => { - const previous = page.offset === 0 ? firstPageResults[position] : null; - if (previous?.page === hit.page && previous.start === hit.start && previous.length === hit.length) - return previous; - return { ...hit, location: `第 ${hit.page} 页`, activate: async nav => { - if (!await goToPage(hit.page, nav) || !isReaderGenerationCurrent("navigation", nav)) - return false; - content.querySelectorAll(".reader-text-hit-box").forEach(node => node.remove()); - const shell = content.querySelector(`.reader-page[data-page="${hit.page}"]`); - if (shell && (hit.boxes.length || hit.textGeneration)) { - let boxes = hit.boxes; - if (hit.textGeneration) { - const layer = await pdfTextLayerRepository.textPage(manifest.text_layer, hit.page); - if (!isReaderGenerationCurrent("navigation", nav)) return false; - if (layer.generation !== hit.textGeneration) throw new Error("PDF_V3_GENERATION_CHANGED"); - boxes = hitBoxes({text: layer.text, text_spans: layer.regions.map(region => ({ - start: region.start, end: region.end, box: region.box, precision: region.mapping_precision - }))}, hit.start, hit.length); - } - if (pdfV3Manifest) { - await renderPdfOcrText(shell); - if (!isReaderGenerationCurrent("navigation", nav)) return false; - } - paintTextHit(shell, boxes, hit); - shell.querySelector(".reader-text-hit-box")?.scrollIntoView({ block: "center" }); - } - return true; - } }; - }); - if (page.offset === 0) firstPageResults = results; - return { ...page, results }; - }; - fullSearchSession.pageLoader = async offset => attach(await client.page(offset)); - const page = await client.search(query, partial => { - if (!isReaderGenerationCurrent("search", generation)) return; - fullSearchSession.page = attach(partial); - updateSearchState({ results: fullSearchSession.page.results, index: -1 }); - renderFullSearchResults(); - fullSearchStatus.textContent = partial.total - ? `${partial.total} 个结果(正在搜索… ${partial.scanned} / ${partial.pages} 页)` - : `正在搜索正文… ${partial.scanned} / ${partial.pages} 页`; - }); - if (!isReaderGenerationCurrent("search", generation)) return []; - fullSearchSession.page = attach(page); - return fullSearchSession.page.results; -} - -function makePdfSearchPageLoader(groups, total, pattern, firstPage) { - const pdf = pdfDocument; - return async (requestedOffset, generation = readerRuntime.currentGeneration("search")) => { - const pageSize = 50, results = []; - const offset = fullSearchPageOffset(total, requestedOffset, pageSize); - if (!offset) return { total, offset, pageSize, results: firstPage.slice() }; - let skipped = 0; - for (const group of groups) { - if (skipped + group.count <= offset) { - skipped += group.count; - continue; - } - const text = await loadPdfSearchText(pdf, group.page, generation); - let examined = 0; - for (const match of text.matchAll(pattern)) { - if (++examined % 2048 === 0) { - await waitForReader(); - if (!isReaderGenerationCurrent("search", generation)) - throw new DOMException("Search cancelled", "AbortError"); - } - if (skipped++ < offset) continue; - results.push({ - location: `第 ${group.page} 页`, - snippet: fullSearchSnippet(text, match.index, match[0].length), - activate: (navigationGeneration) => goToPage(group.page, navigationGeneration) - }); - if (results.length === pageSize) break; - } - if (results.length === pageSize) break; - } - return { total, offset, pageSize, results }; - }; -} - -function publishPdfSearchProgress(generation, groups, total, pattern, firstPage, scanned, pages) { - if (!isReaderGenerationCurrent("search", generation)) return false; - fullSearchSession.pageLoader = makePdfSearchPageLoader(groups, total, pattern, firstPage); - fullSearchSession.page = { total, offset: 0, pageSize: 50, results: firstPage.slice() }; - updateSearchState({ results: fullSearchSession.page.results, index: -1 }); - renderFullSearchResults(); - fullSearchStatus.textContent = total - ? `${total} 个结果(正在搜索… ${scanned} / ${pages} 页)` - : `正在搜索正文… ${scanned} / ${pages} 页,暂未找到匹配`; - return true; -} - const pdfSearchTextCache = new Map(); let pdfPersistentTextStore = null, pdfPersistentTextOpening = null, pdfPersistentTextVersion = null; -async function getPdfPersistentTextStore(pdf) { - const version = pdfFetchPolicy?.identity; - if (!version || ocrManifestUrl || readerAbortController.signal.aborted) return null; - if (pdfPersistentTextVersion !== version) { - pdfPersistentTextStore?.dispose(); - pdfPersistentTextStore = null; - pdfPersistentTextVersion = version; - const pending = (async () => { - let timer; - const module = await awaitReader(Promise.race([import("./reader-pdf-text-store.mjs"), - new Promise(resolve => {timer=setTimeout(()=>resolve(null),1000);})])).finally(()=>clearTimeout(timer)); - if (!module) return null; - const key = await module.pdfTextStoreKey({source:sourceUrl,version,pageCount:pdf.numPages, - engine:VoiceOfMLReaderResources.vendors.pdf.sha256}); - if (!key || readerAbortController.signal.aborted || pdfFetchPolicy?.identity !== version || pdfPersistentTextOpening !== pending) return null; - return pdfPersistentTextStore = module.createPdfTextStore(key,{signal:readerAbortController.signal}); - })().catch(() => null); - pdfPersistentTextOpening = pending; - } - return pdfPersistentTextStore || pdfPersistentTextOpening; -} + const PDF_SEARCH_TEXT_CACHE_BYTES = 8 * 1024 * 1024; let pdfSearchTextCacheBytes = 0; -function cachePdfSearchText(page, text) { - const bytes = text.length * 2; - if (bytes > PDF_SEARCH_TEXT_CACHE_BYTES) return; - const previous = pdfSearchTextCache.get(page); - if (previous !== undefined) { - pdfSearchTextCacheBytes -= previous.length * 2; - pdfSearchTextCache.delete(page); - } - pdfSearchTextCache.set(page, text); - pdfSearchTextCacheBytes += bytes; - while (pdfSearchTextCacheBytes > PDF_SEARCH_TEXT_CACHE_BYTES) { - const oldest = pdfSearchTextCache.keys().next().value; - pdfSearchTextCacheBytes -= pdfSearchTextCache.get(oldest).length * 2; - pdfSearchTextCache.delete(oldest); - } -} -trackReaderResource(() => { - pdfPersistentTextStore?.dispose(); - pdfPersistentTextStore = pdfPersistentTextOpening = pdfPersistentTextVersion = null; - pdfSearchTextCache.clear(); - pdfSearchTextCacheBytes = 0; -}); -async function loadPdfSearchText(pdf, page, generation) { - if (!isReaderGenerationCurrent("search", generation)) - throw new DOMException("Search cancelled", "AbortError"); - let text = pdfSearchTextCache.get(page); - if (text !== undefined) { - pdfSearchTextCache.delete(page); - pdfSearchTextCache.set(page, text); - return text; - } - const store = await getPdfPersistentTextStore(pdf); - const version = pdfFetchPolicy?.identity; - const stored = store ? await store.get(page).catch(() => null) : null; - if (!isReaderGenerationCurrent("search", generation)) throw readerAbortError(); - if (stored !== null && version && version === pdfFetchPolicy?.identity) { - cachePdfSearchText(page, stored); - return stored; - } - const pdfPage = await awaitReader(pdf.getPage(page)); - if (!isReaderGenerationCurrent("search", generation)) - throw new DOMException("Search cancelled", "AbortError"); - const textContent = await loadPdfTextContent(page, pdfPage, generation); - if (!isReaderGenerationCurrent("search", generation)) - throw new DOMException("Search cancelled", "AbortError"); - text = normalizePdfSearchText(pdfTextContent(textContent.items)); - cachePdfSearchText(page, text); - if (store && version && version === pdfFetchPolicy?.identity) store.put(page,text).catch(() => {}); - return text; -} -async function fullSearchPdfMatches(query, generation) { - const pdf = pdfDocument, - groups = [], firstPage = [], - pattern = new RegExp(fullSearchEscape(normalizePdfSearchText(query)), "giu"); - if (!pdf) return []; - const pending = new Map(); - let nextPage = 1; - const fill = () => { - while (pending.size < 2 && nextPage <= pdf.numPages && isReaderGenerationCurrent("search", generation)) { - const page = nextPage++; - pending.set(page, loadPdfSearchText(pdf, page, generation).then( - text => ({ text }), error => ({ error }))); - } - }; - fill(); - let total = 0, hasText = false; - for (let page = 1; page <= pdf.numPages; page++) { - if (!isReaderGenerationCurrent("search", generation)) return []; - const result = await pending.get(page); - pending.delete(page); - if (!isReaderGenerationCurrent("search", generation)) return []; - if (result.error) throw result.error; - const text = result.text; - fill(); - hasText ||= !!text.trim(); - let count = 0; - for (const match of text.matchAll(pattern)) { - count++; - if (firstPage.length < 50) firstPage.push({ - location: `第 ${page} 页`, - snippet: fullSearchSnippet(text, match.index, match[0].length), - activate: (navigationGeneration) => goToPage(page, navigationGeneration) - }); - if (count % 2048 === 0) { - await waitForReader(); - if (!isReaderGenerationCurrent("search", generation)) return []; - } - } - if (count) { - groups.push({ page, count }); - total += count; - highlightPdfText(content.querySelector(`.reader-page[data-page="${page}"]`), query); - } - if (page === 1 || (count && total <= 50) || page % 8 === 0 || page === pdf.numPages) { - if (!publishPdfSearchProgress(generation, groups, total, pattern, firstPage, page, pdf.numPages)) return []; - await waitForReader(0, true); - } - } - if (!hasText) throw new Error("此 PDF 尚无可搜索文字层,需要完成 OCR 后才能搜索正文"); - publishPdfSearchProgress(generation, groups, total, pattern, firstPage, pdf.numPages, pdf.numPages); - return fullSearchSession.page.results; -} - -async function searchReaderText(query, generation) { - if ((capability.mode === "pdf" || pdfHybridUrl) && !ocrManifestUrl) { - if (pdfHybridUrl) await pdfHybridDocumentPromise; - await fullSearchPdfMatches(query, generation); - } - else if (["pdf", "pdf-pages"].includes(capability.mode)) - publishSearchResults(generation, await searchConcentratedPdf(query, generation)); - else if (capability.mode === "foliate") - publishSearchResults(generation, await fullSearchFoliateMatches(query, generation)); - else if (capability.mode === "epub-chapters") await searchChapterBook(query, generation); - else if (["text", "markdown", "docx"].includes(capability.mode)) - publishSearchResults( - generation, - await fullSearchDomMatches(content, "阅读位置", query, generation, true) - ); - else if (capability.mode === "html") - publishSearchResults( - generation, - htmlFrame?.contentDocument?.body - ? await fullSearchDomMatches( - htmlFrame.contentDocument.body, - "HTML 阅读位置", - query, - generation, - true - ) - : [] - ); - else throw new Error("此格式没有可搜索文本"); -} -function syncCapabilityControls() { - content.dataset.mode = capability.mode || "unsupported"; - pageInvertToggle.hidden = !["pdf", "pdf-pages"].includes(capability.mode); - document.querySelector(".page-controls").hidden = !capability.features.pagination; - document.querySelector(".zoom-controls").hidden = !capability.features.zoom; - mediaTab.hidden = !capability.features.media; - fullSearchButton.hidden = !capability.features.search || (capability.mode === "pdf-pages" && !ocrManifestUrl && !pdfHybridUrl); - bookmarkRibbon.hidden = !capability.features.bookmarks; - readingProgressSummary.querySelector(".reader-progress-bookmark").hidden = - !capability.features.bookmarks; - if ( - (panelState.selected === "media" && mediaTab.hidden) || - (panelState.selected === "full-search" && fullSearchButton.hidden) - ) - selectPanel("toc"); -} -registerReaderFormatAdapters(); -syncCapabilityControls(); -updateProgressTools(); -start().catch((error) => { - if (readerAbortController.signal.aborted || error?.name === "AbortError") return; - console.error(error); - fail("阅读文件解析失败,请刷新后重试,或下载原文件。", classifyReaderError(error)); -}); - -function refreshNearbyPdfPages() { - if (!documentState.restorationReady || !["pdf", "pdf-pages", "image-pages"].includes(capability.mode)) return; - const page = documentState.page; - for (const offset of [0, 1, 2, 3, -1]) { - const candidate = page + offset * pdfScrollDirection; - const shell = content.querySelector(`.reader-page[data-page="${candidate}"]`); - if (shell && shell.dataset.renderState !== "rendered") renderPdfInBackground(shell); - } -} - -function memoizeDocxResource(load, keyFor, isReleased) { - const pending = new Map(); - return (...args) => { - if (isReleased()) return Promise.resolve(null); - const key = keyFor(...args); - if (!key) return load(...args); - if (!pending.has(key)) { - const task = Promise.resolve().then(() => isReleased() ? null : load(...args)); - pending.set(key, task); - task.catch(() => { if (pending.get(key) === task) pending.delete(key); }); - } - return pending.get(key); - }; -} - -function memoizeDocxAssets(doc, isReleased) { - if (typeof doc.getPathById !== "function") return; - for (const [name, part] of [["loadDocumentImage", "documentPart"], - ["loadNumberingImage", "numberingPart"], ["loadFont", "fontTablePart"]]) { - if (typeof doc[name] !== "function") continue; - const original = doc[name].bind(doc); - doc[name] = memoizeDocxResource(original, (id, parameter) => { - const path = doc.getPathById(name === "loadDocumentImage" ? parameter || doc[part] : doc[part], id); - return path ? (name === "loadFont" ? `${path}\u0000${parameter}` : path) : null; - }, isReleased); - } -} +readerBindings.ownPdfSearchStorageAndStart(); diff --git a/static/reader/core/reader-bindings.mjs b/static/reader/core/reader-bindings.mjs new file mode 100644 index 0000000000000000000000000000000000000000..106c69d4c73aeff5ae06f52bb1f3ac6bb008b950 --- /dev/null +++ b/static/reader/core/reader-bindings.mjs @@ -0,0 +1,847 @@ +export function createReaderBindings(readerContext) { + function trackStartupOwners() { + readerContext.readerRuntime.track(readerContext.readerRequestManager); + readerContext.readerRuntime.track(readerContext.formatAdapters); + readerContext.readerRuntime.track(window.__VOICE_PDF_PRELOAD__); + readerContext.readerRuntime.track(window.__VOICE_READER_CHAPTER_PRELOAD__); + } + + function trackWorkCancellation() { + readerContext.readerRuntime.track(readerContext.stopReaderWork); + } + + function trackEarlySourceResolution() { + readerContext.trackReaderResource(() => window.__VOICE_READER_RESOLVE__?.dispose()); + } + + function publishDiagnosticsAndInstallCompatibility() { + window.VoiceOfMLReaderPdfDiagnostics = readerContext.pdfDiagnostics; + readerContext.trackReaderResource(() => { + readerContext.pdfPageTimings.length = 0; + if (window.VoiceOfMLReaderPdfDiagnostics === readerContext.pdfDiagnostics) delete window.VoiceOfMLReaderPdfDiagnostics; + }); + + // Polyfills for vendor engines (pdf.js, foliate): neither helper has a direct + // caller in first-party code, but both are required by pinned vendor bundles. + if (!Map.prototype.getOrInsertComputed) { + Map.prototype.getOrInsertComputed = function (key, callback) { + if (this.has(key)) return this.get(key); + const value = callback(key); + this.set(key, value); + return value; + }; + } + if (!Math.sumPrecise) { + Math.sumPrecise = function (values) { + let sum = 0; + let correction = 0; + for (const value of values) { + const next = sum + value; + correction += Math.abs(sum) >= Math.abs(value) ? sum - next + value : value - next + sum; + sum = next; + } + return sum + correction; + }; + } + } + + function readSelectedSourceSession() { + try { + readerContext.localReaderData = JSON.parse(sessionStorage.getItem(`reader-source:${readerContext.readerId}`) || "null"); + if (readerContext.localReaderData) sessionStorage.removeItem(`reader-source:${readerContext.readerId}`); + } catch (_) {} + } + + function normalizeConvertedPdfMode() { + if ( + readerContext.extension === "pdf" && + /\/api\/reader-bucket-resource\?/.test(readerContext.sourceUrl) && + new URL(readerContext.sourceUrl, location.href).searchParams.get("path")?.endsWith("page-manifest.json") + ) { + readerContext.extension = "pdf-pages"; + readerContext.capability = readerContext.readerRuntime.negotiate(VoiceOfMLReader.capability(readerContext.extension)); + } + } + + function applySourceSessionAndCachedMetadata() { + if (readerContext.localReaderData) { + if (readerContext.localReaderData.title) readerContext.params.set("title", readerContext.localReaderData.title); + if (readerContext.localReaderData.extension) readerContext.params.set("ext", readerContext.localReaderData.extension); + if (readerContext.localReaderData.repo) + readerContext.params.set("path", [readerContext.localReaderData.repo, ...(readerContext.localReaderData.folder || [])].join("/")); + } + try { + const cached = sessionStorage.getItem(`reader-resolve:${readerContext.readerId}`); + if (cached) { + readerContext.cachedReaderData = JSON.parse(cached); + readerContext.resolvedReaderData = readerContext.cachedReaderData; + sessionStorage.removeItem(`reader-resolve:${readerContext.readerId}`); + } + } catch (_) {} + } + + function publishSourceAndSubscribeToStore() { + readerContext.readerRuntime.update("source", { + id: readerContext.readerId, + url: readerContext.sourceUrl, + contentUrl: readerContext.contentUrl, + downloadUrl: readerContext.downloadUrl, + extension: readerContext.extension, + metadata: readerContext.resolvedReaderData + }); + readerContext.readerRuntime.events.on("phase", ({ phase }) => { + document.documentElement.dataset.readerPhase = phase; + }); + readerContext.readerRuntime.track( + VoiceOfMLReaderStore.subscribe?.((change) => { + if (!change?.remote || readerContext.readerLifecycle.disposed) return; + if ( + change.type === "history-clear" || + (change.type === "history-remove" && change.url === readerContext.sourceUrl) + ) { + readerContext.historySuppressed = true; + readerContext.readerRuntime.cancel(readerContext.saveTimer); + readerContext.saveTimer = readerContext.saveDeadline = 0; + } + if ( + ["history", "history-clear", "history-remove"].includes(change.type) && + !document.querySelector("#history-panel").hidden + ) + readerContext.renderHistory(); + if ( + ["bookmark", "bookmark-remove"].includes(change.type) && + (readerContext.panelState.showingAllBookmarks || !change.url || change.url === readerContext.sourceUrl) && + !document.querySelector("#bookmarks-panel").hidden + ) + readerContext.renderBookmarks(); + }) + ); + + readerContext.setReaderPhase("startup"); + window.fetchFile = async (url) => { + const requestUrl = String(url) === readerContext.sourceUrl ? readerContext.contentUrl : url; + const response = await readerContext.readerRequestManager.request(requestUrl, readerContext.READER_PROXY_TIMEOUT_MS); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + const fileName = new URL(url, location.href).pathname.split("/").pop() || "book"; + return new File( + [ + await VoiceOfMLReaderSecurity.readBytes( + response, + VoiceOfMLReaderSecurity.LIMITS.archiveCompressedBytes + ) + ], + fileName, + { type: response.headers.get("content-type") || "application/octet-stream" } + ); + }; + } + + function initializePanelAccessibility() { + document.querySelector(".reader-panel-tabs")?.setAttribute("role", "tablist"); + } + + function mountInitialLoadingFeedback() { + readerContext.loadingIndicator.className = "reader-loading-indicator"; + readerContext.loadingIndicator.setAttribute("role", "status"); + readerContext.loadingIndicator.innerHTML = + '正在加载正文...'; + readerContext.content.appendChild(readerContext.loadingIndicator); + readerContext.content.dataset.mode = readerContext.capability.mode || "unsupported"; + } + + function bindDownloadAction() { + readerContext.downloadButton.addEventListener("click", readerContext.startReaderDownload); + readerContext.loadingStatus.textContent = ""; + } + + function initializeDocumentTitle() { + readerContext.updateDocumentState({ + title: + String( + readerContext.params.get("title") || (readerContext.localReaderData && readerContext.localReaderData.title) || readerContext.sourceName + ).trim() || readerContext.sourceName, + zoom: Math.min(4, Math.max(0.25, Number(localStorage.getItem("reader-zoom") || 100) / 100)) + }); + } + + function restoreReturnNavigation() { + try { + const state = history.state; + const saved = JSON.parse(sessionStorage.getItem("reader-navigation-current") || "null"); + let stateUrl = + state && state.voiceReaderOverlay && state.readerUrl + ? new URL(state.readerUrl, location.origin) + : null; + if (!stateUrl && saved && saved.shareUrl === location.href && saved.readerUrl) + stateUrl = new URL(saved.readerUrl, location.origin); + const cleanStateUrl = stateUrl && new URL(stateUrl.href); + if (cleanStateUrl) { + cleanStateUrl.searchParams.delete("return"); + cleanStateUrl.searchParams.delete("nav"); + } + if ( + stateUrl && + stateUrl.origin === location.origin && + stateUrl.pathname === "/static/reader.html" && + cleanStateUrl.href === location.href + ) { + if (!readerContext.returnUrl) readerContext.returnUrl = stateUrl.searchParams.get("return") || ""; + if (!readerContext.returnNavigationToken) readerContext.returnNavigationToken = stateUrl.searchParams.get("nav") || ""; + readerContext.returnNeedsReload = true; + } + } catch (_) {} + } + + function validateReturnHistory() { + try { + const target = new URL(readerContext.returnUrl, location.origin); + const storedReturnUrl = readerContext.returnHistoryKey ? sessionStorage.getItem(readerContext.returnHistoryKey) : ""; + readerContext.canReturnWithHistory = !!readerContext.returnHistoryKey && storedReturnUrl === target.href; + } catch (_) {} + } + + function ownChapterSearchClient() { + readerContext.trackReaderResource(() => { + readerContext.chapterSearchClient?.dispose(); + readerContext.chapterSearchClient = null; + }); + } + + function ownDocxPageNodes() { + readerContext.trackReaderResource(() => { readerContext.docxPageNodes = []; }); + } + + function ownIndependentPdfResources() { + readerContext.trackReaderResource(() => { + for (const release of [...readerContext.pdfV3BlobReleases]) release(); + readerContext.pdfV3Repository?.dispose(); readerContext.pdfTextLayerRepository?.dispose(); + }); + } + + function ownPdfBookSearchClient() { + readerContext.trackReaderResource(() => { + readerContext.pdfBookSearchClient?.dispose(); + readerContext.pdfBookSearchClient = null; + clearTimeout(readerContext.pdfBookSearchPrefetchTimer); + readerContext.pdfBookSearchPrefetchTimer = 0; + readerContext.pdfBookSearchPrefetchPromise = null; + }); + } + + function ownPdfPrefetchAndQueues() { + readerContext.trackReaderResource(readerContext.clearPdfManifestPrefetches); + readerContext.trackReaderResource(() => { + for (const waiter of readerContext.pdfRenderWaiters.splice(0)) waiter.reject(readerContext.readerAbortError()); + for (const waiter of readerContext.pdfTextWaiters.splice(0)) waiter.reject(readerContext.readerAbortError()); + }); + } + + function initializeGenerations() { + for (const generationName of ["navigation", "search", "bookmarks", "history", "pdf"]) + readerContext.nextReaderGeneration(generationName); + } + + function labelToolbarControls() { + for (const [selector, label] of [ + ["#back", "返回"], + ["#page-prev", "上一页"], + ["#page-next", "下一页"], + ["#page-number", "页码"], + ["#zoom-out", "缩小"], + ["#zoom-in", "放大"], + ["#zoom", "缩放百分比"], + ["#bookmark-ribbon", "添加书签"], + ["#history", "阅读选项"] + ]) + document.querySelector(selector)?.setAttribute("aria-label", label); + } + + function applyMetadataAndWatchSanitizedContent() { + readerContext.applyReaderMetadata(readerContext.resolvedReaderData); + readerContext.contentSanitizerObserver = new MutationObserver(() => { + const seen = new Set(); + for (const article of document.querySelectorAll(".foliate-continuous > article[data-section]")) { + article.querySelectorAll("style,link[rel=stylesheet]").forEach((node) => node.remove()); + const index = article.dataset.section; + if (seen.has(index)) article.remove(); + else seen.add(index); + } + }); + readerContext.contentSanitizerObserver.observe(readerContext.content, { childList: true, subtree: true }); + readerContext.bookmarkPopover.className = "bookmark-popover"; + readerContext.bookmarkPopover.setAttribute("role", "dialog"); + readerContext.bookmarkPopover.setAttribute("aria-modal", "true"); + readerContext.bookmarkPopover.setAttribute("aria-labelledby", "bookmark-prompt"); + } + + function initializeBookmarkEditor() { + readerContext.bookmarkLabelInput.id = "bookmark-label"; + readerContext.bookmarkLabelInput.maxLength = 120; + readerContext.bookmarkExcerptInput.id = "bookmark-excerpt-input"; + readerContext.bookmarkExcerptInput.maxLength = 500; + readerContext.bookmarkExcerptInput.rows = 3; + readerContext.bookmarkEditFields.className = "bookmark-edit-fields"; + readerContext.bookmarkEditFields.innerHTML = ""; + readerContext.bookmarkEditFields.children[0].appendChild(readerContext.bookmarkLabelInput); + readerContext.bookmarkEditFields.children[1].appendChild(readerContext.bookmarkExcerptInput); + readerContext.bookmarkPopover.insertBefore(readerContext.bookmarkEditFields, readerContext.bookmarkPopover.querySelector("div")); + } + + function initializeBookmarkSearchControls() { + if (!document.querySelector("#bookmarks-panel .panel-search-toggle")) { + const button = document.createElement("button"); + button.className = "panel-search-toggle"; + button.type = "button"; + document.querySelector("#bookmarks-panel .panel-view-header").appendChild(button); + } + readerContext.bookmarksAllButton.id = "bookmarks-all"; + readerContext.bookmarksAllButton.className = "text-action"; + readerContext.bookmarksAllButton.type = "button"; + readerContext.bookmarksAllButton.textContent = "全部书签"; + readerContext.bookmarksAllButton.setAttribute("aria-pressed", "false"); + } + + function mountBookmarkHeaderActions() { + readerContext.bookmarksHeaderActions.append(readerContext.bookmarksAllButton, readerContext.bookmarksSearchButton); + readerContext.bookmarksHeader.appendChild(readerContext.bookmarksHeaderActions); + } + + function mountMediaPanel() { + readerContext.mediaTab.id = "media-tab"; + readerContext.mediaTab.type = "button"; + readerContext.mediaTab.role = "tab"; + readerContext.mediaTab.dataset.panel = "media"; + readerContext.mediaTab.textContent = "播放"; + readerContext.mediaTab.hidden = !readerContext.capability.features.media; + readerContext.mediaPanel.id = "media-panel"; + readerContext.mediaPanel.className = "reader-panel-view"; + readerContext.mediaPanel.dataset.panelView = "media"; + readerContext.mediaPanel.hidden = true; + readerContext.mediaPanel.innerHTML = + '
播放状态尚未播放
'; + document.querySelector(".reader-panel-tabs").prepend(readerContext.mediaTab); + document + .querySelector("#history-panel") + .insertBefore(readerContext.mediaPanel, document.querySelector("#toc-panel")); + readerContext.mediaPanel + .querySelector(".media-panel-bookmark") + .addEventListener("click", (event) => readerContext.openBookmarkPopover(event.currentTarget)); + document.querySelector(".page-controls").hidden = !readerContext.capability.features.pagination; + document.querySelector(".zoom-controls").hidden = !readerContext.capability.features.zoom; + + readerContext.status.hidden = true; + readerContext.mediaTab.setAttribute("aria-controls", "media-panel"); + readerContext.mediaTab.setAttribute("aria-selected", "false"); + readerContext.mediaPanel.setAttribute("role", "tabpanel"); + readerContext.mediaPanel.setAttribute("aria-labelledby", "media-tab"); + } + + function restoreAndBindPageInversion() { + try { readerContext.pageInverted = localStorage.getItem("reader-page-inversion") === "true"; } catch (_) {} + + readerContext.pageInvertToggle.addEventListener("click", () => { + readerContext.pageInverted = !readerContext.pageInverted; + readerContext.applyPageInversion(true); + }); + readerContext.applyPageInversion(); + } + + function restoreReaderThemeAndBindReturn() { + readerContext.readerThemeToggle.className = "theme-toggle"; + + readerContext.applyReaderTheme(readerContext.readerTheme, false, false); + + readerContext.readerPath.addEventListener("click", readerContext.navigateReaderFolder); + document.querySelector("#back").addEventListener("click", async () => { + clearTimeout(readerContext.saveTimer); + await Promise.race([readerContext.saveProgress(), new Promise((resolve) => setTimeout(resolve, 300))]); + if (window.parent !== window) { + window.parent.postMessage({ type: "voice-reader-close" }, location.origin); + return; + } + readerContext.clearReturnNavigation(); + try { + const target = new URL(readerContext.returnUrl, location.origin); + if (target.origin === location.origin) { + if (readerContext.returnNeedsReload) location.replace(target.href); + else if (readerContext.canReturnWithHistory && history.length > 2) { + history.go(-2); + } else location.assign(target.href); + return; + } + } catch (_) {} + location.assign("/"); + }); + } + + function bindZoomAndPageControls() { + readerContext.setZoom(readerContext.documentState.zoom * 100, false); + for (const [id, delta] of [ + ["#zoom-out", -10], + ["#zoom-in", 10] + ]) { + document.querySelector(id).addEventListener("click", () => { + readerContext.setZoom(Number(readerContext.zoomInput.value) + delta); + }); + } + readerContext.zoomInput.addEventListener("change", () => readerContext.setZoom(readerContext.zoomInput.value)); + readerContext.zoomInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + readerContext.setZoom(readerContext.zoomInput.value); + readerContext.zoomInput.blur(); + } + }); + readerContext.pageInput.addEventListener("change", () => readerContext.goToPage(readerContext.pageInput.value)); + readerContext.pageInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + readerContext.goToPage(readerContext.pageInput.value); + readerContext.pageInput.blur(); + } + }); + + document.addEventListener("keydown", readerContext.handleReaderKeydown); + document + .querySelector("#page-prev") + .addEventListener("click", () => + readerContext.documentState.pageCount ? readerContext.goToPage(readerContext.documentState.page - 1) : readerContext.turnViewport(-1) + ); + document + .querySelector("#page-next") + .addEventListener("click", () => + readerContext.documentState.pageCount ? readerContext.goToPage(readerContext.documentState.page + 1) : readerContext.turnViewport(1) + ); + } + + function bindReaderUiHistory() { + if (window.parent === window) { + history.replaceState({ ...history.state, voiceReaderBase: true }, "", location.href); + history.pushState({ voiceReaderGuard: true }, "", location.href); + window.addEventListener("popstate", (event) => { + if (!event.state?.voiceReaderBase || readerContext.readerLifecycle.disposed) return; + if (document.querySelector("#history-panel").classList.contains("is-open") && !readerContext.fullSearchView.hidden) + readerContext.closeFullSearchView(); + else if (document.querySelector("#history-panel").classList.contains("is-open")) + readerContext.setReaderPanelOpen(false, true); + else { + history.back(); + return; + } + history.pushState({ voiceReaderGuard: true }, "", location.href); + }); + } + } + + function bindPanelBookmarkAndLifecycleControls() { + readerContext.trackReaderResource(() => { readerContext.headingLocationCache?.dispose(); readerContext.headingLocationCache = null; }); + + document.addEventListener("click", async (event) => { + const path = event.composedPath(), + link = path.find((node) => node instanceof Element && node.matches?.("a[href]")), + article = path.find( + (node) => + node instanceof Element && node.matches?.(".foliate-continuous article[data-section]") + ); + if (!link || !article || !readerContext.epubRendition?.book) return; + const raw = link.getAttribute("href") || ""; + if (raw.startsWith("/api/reader-resource?")) return; + if (!raw || readerContext.epubRendition.book.isExternal?.(raw) || /^(?:https?:|mailto:|tel:)/i.test(raw)) + return; + event.preventDefault(); + const generation = readerContext.beginReaderNavigation(), + index = Number(article.dataset.section), + section = readerContext.epubRendition.book.sections.filter((item) => item.linear !== "no")[index]; + let href = raw; + try { + href = section?.resolveHref?.(raw) || raw; + } catch (_) {} + try { + await readerContext.activateFoliateTocEntry({ href }, generation); + } catch (error) { + readerContext.reportNavigationError(error, generation); + } + }); + + // Bookmark dialog owns focus and temporarily makes only its siblings inert. + + readerContext.bookmarkRibbon.addEventListener("click", () => readerContext.openBookmarkPopover(readerContext.bookmarkRibbon)); + document.querySelector("#bookmark-cancel").addEventListener("click", readerContext.closeBookmarkPopover); + document.querySelector("#bookmark-add").addEventListener("click", async (event) => { + const button = event.currentTarget, + editing = readerContext.panelState.editingBookmark; + const snapshot = readerContext.pendingBookmarkSnapshot || editing || readerContext.captureBookmarkSnapshot(); + const label = readerContext.bookmarkLabelInput.value.trim() || snapshot.label, + excerpt = readerContext.bookmarkExcerptInput.value.trim(); + button.disabled = true; + try { + await VoiceOfMLReaderStore.putBookmark( + editing + ? { ...editing, label, excerpt } + : { + ...snapshot, + id: `${readerContext.sourceUrl}\0${snapshot.locator}`, + url: readerContext.sourceUrl, + title: readerContext.documentState.title, + extension: readerContext.extension, + readerUrl: readerContext.currentReaderUrl().href, + label, + excerpt, + createdAt: Date.now() + } + ); + const invoker = readerContext.bookmarkInvoker; + readerContext.closeBookmarkPopover(); + if (!document.querySelector("#bookmarks-panel").hidden) { + await readerContext.renderBookmarks(); + if (editing && invoker && !invoker.isConnected && document.activeElement === document.body) + ( + document.querySelector( + `#bookmarks-list [data-bookmark-id="${CSS.escape(editing.id)}"] .panel-item-edit` + ) || readerContext.bookmarksAllButton + ).focus(); + } + } catch (_) { + document.querySelector("#bookmark-prompt").textContent = "书签保存失败,请重试。"; + } finally { + button.disabled = false; + } + }); + readerContext.bookmarkPopover.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + event.preventDefault(); + readerContext.closeBookmarkPopover(); + return; + } + if (event.key !== "Tab") return; + const controls = [ + ...readerContext.bookmarkPopover.querySelectorAll("input,textarea,select,button,a[href],[tabindex]") + ].filter((node) => !node.disabled && node.tabIndex >= 0 && node.getClientRects().length); + const first = controls[0], + last = controls[controls.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }); + for (const button of document.querySelectorAll(".reader-panel-tabs button")) + button.addEventListener("click", () => readerContext.selectPanel(button.dataset.panel)); + document.querySelector(".reader-panel-tabs").addEventListener("keydown", (event) => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + const tabs = [...document.querySelectorAll(".reader-panel-tabs button:not([hidden])")], + current = tabs.indexOf(document.activeElement); + if (current < 0) return; + event.preventDefault(); + const next = + event.key === "Home" + ? 0 + : event.key === "End" + ? tabs.length - 1 + : (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length; + readerContext.selectPanel(tabs[next].dataset.panel); + tabs[next].focus(); + }); + readerContext.bookmarksAllButton.addEventListener("click", () => { + readerContext.updatePanelState({ showingAllBookmarks: !readerContext.panelState.showingAllBookmarks }); + readerContext.bookmarksAllButton.textContent = readerContext.panelState.showingAllBookmarks ? "本书书签" : "全部书签"; + readerContext.bookmarksAllButton.setAttribute("aria-pressed", String(readerContext.panelState.showingAllBookmarks)); + readerContext.renderBookmarks(); + }); + for (const button of document.querySelectorAll(".panel-search-toggle")) { + button.classList.remove("icon-button"); + button.classList.add("text-action"); + button.textContent = "搜索"; + button.hidden = false; + button.setAttribute("aria-expanded", "false"); + button.addEventListener("click", () => + readerContext.setPanelSearchOpen(button, button.getAttribute("aria-expanded") !== "true") + ); + } + for (const input of document.querySelectorAll(".panel-search")) { + const view = input.closest(".reader-panel-view"); + input.type = "search"; + input.setAttribute( + "aria-label", + `搜索${view.querySelector(".panel-view-header strong")?.textContent || "列表"}` + ); + input.addEventListener("input", (event) => { + if (!event.isComposing) readerContext.filterPanel(view); + }); + input.addEventListener("compositionend", () => readerContext.filterPanel(view)); + } + document.querySelector("#history-clear").addEventListener("click", async () => { + if (!confirm("清空全部阅读历史?")) return; + readerContext.historySuppressed = true; + await readerContext.progressSaveChain; + await VoiceOfMLReaderStore.clearHistory(); + readerContext.renderHistory(); + }); + readerContext.readerThemeToggle.addEventListener("click", () => { + const theme = readerContext.readerTheme === "dark" ? "light" : "dark"; + readerContext.applyReaderTheme(theme); + if (window.parent !== window) + window.parent.postMessage({ type: "voice-reader-theme", theme }, location.origin); + }); + window.addEventListener("storage", (event) => { + if (event.key === "theme" && event.newValue) readerContext.applyReaderTheme(event.newValue, false); + }); + window.addEventListener("voice-reader-dispose", () => { + readerContext.saveProgress(); + readerContext.disposeReader(); + }); + window.addEventListener("message", (event) => { + if ( + event.origin === location.origin && + event.source === window.parent && + event.data?.type === "voice-reader-abort" + ) { + readerContext.saveProgress(); + readerContext.disposeReader(); + return; + } + if ( + event.origin === location.origin && + event.source === window.parent && + event.data && + event.data.type === "voice-reader-theme-state" + ) + readerContext.applyReaderTheme(event.data.theme, false); + if (event.origin === location.origin && event.source === window.parent && + event.data?.type === "voice-reader-system-back") { + if (readerContext.readerUiHistoryState === "full-search") readerContext.closeFullSearchView(); + else if (readerContext.readerUiHistoryState === "panel") readerContext.setReaderPanelOpen(false, true); + } + }); + document + .querySelector("#history") + .addEventListener("click", () => + readerContext.setReaderPanelOpen(document.querySelector("#history").getAttribute("aria-expanded") !== "true") + ); + document + .querySelector("#history-close") + .addEventListener("click", () => readerContext.setReaderPanelOpen(false, true)); + readerContext.viewport.addEventListener("scroll", readerContext.handleReaderPositionChange, { passive: true }); + + for (const type of ["wheel", "touchstart", "pointerdown"]) + readerContext.viewport.addEventListener(type, readerContext.notePdfScrollIntent, { passive: true }); + } + + function bindPdfPointerSelection() { + readerContext.installReaderTapSelectionClear(readerContext.viewport); + readerContext.viewport.addEventListener("pointerdown", (event) => { + if (event.pointerType !== "mouse") { + readerContext.pdfSelectionPointer = null; + return; + } + const run = event.target.closest?.(".reader-pdf-text-run"); + const selection = document.getSelection(); + const selectedRun = !selection?.isCollapsed && event.target.closest?.(".reader-page") + ? selection.anchorNode?.parentElement?.closest(".reader-pdf-text-run") : null; + const origin = run || selectedRun; + const caret = !event.shiftKey && event.button === 0 ? readerContext.independentTextCaret(document, event.clientX, event.clientY) : null; + readerContext.pdfSelectionPointer = origin + ? { id: event.pointerId, x: event.clientX, y: event.clientY, frame: 0, + page: origin.closest(".reader-page"), anchorNode: caret?.node || (run ? null : selection.anchorNode), + anchorOffset: caret?.offset ?? (run ? 0 : selection.anchorOffset) } : null; + }, { passive: true }); + document.addEventListener("pointermove", (event) => { + const start = readerContext.pdfSelectionPointer; + if (event.pointerType !== "mouse" || !start || start.id !== event.pointerId || + Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) return; + const selection = document.getSelection(); + if (!start.anchorNode && selection?.rangeCount && start.page.contains(selection.anchorNode) && + selection.anchorNode.parentElement?.closest(".reader-pdf-text-run")) { + start.anchorNode = selection.anchorNode; + start.anchorOffset = selection.anchorOffset; + } + if (start.anchorNode) { + start.endX = event.clientX; + start.endY = event.clientY; + if (!start.frame) start.frame = requestAnimationFrame(() => { + start.frame = 0; + if (readerContext.pdfSelectionPointer === start) + readerContext.correctPdfCrossPageSelection(start.endX, start.endY, start); + }); + } + }, { passive: true }); + document.addEventListener("pointerup", (event) => { + const start = readerContext.pdfSelectionPointer; + readerContext.pdfSelectionPointer = null; + if (event.pointerType !== "mouse" || !["pdf", "pdf-pages"].includes(readerContext.capability.mode) || + !start || start.id !== event.pointerId || + Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) return; + const x = event.clientX, y = event.clientY; + requestAnimationFrame(() => readerContext.correctPdfCrossPageSelection(x, y, start)); + }, { passive: true }); + document.addEventListener("pointercancel", () => { readerContext.pdfSelectionPointer = null; }, { passive: true }); + } + + function bindSelectionAndPageLifecycle() { + document.addEventListener("selectionchange", () => { + if (readerContext.pdfSelectionBoundaryUpdate || + !["pdf", "pdf-pages"].includes(readerContext.capability.mode)) return; + const selection = document.getSelection(); + if (!selection || selection.isCollapsed || !selection.rangeCount) return; + const runs = [...readerContext.content.querySelectorAll(".reader-pdf-text-run")] + .filter((run) => run.firstChild?.nodeType === Node.TEXT_NODE); + if (!runs.length) return; + const anchorRun = selection.anchorNode?.parentElement?.closest(".reader-pdf-text-run"); + const focusRun = selection.focusNode?.parentElement?.closest(".reader-pdf-text-run"); + if (!anchorRun && !focusRun) return; + if (focusRun) return; + const beforeAnchor = selection.focusNode && selection.anchorNode && + (selection.focusNode === selection.anchorNode + ? selection.focusOffset < selection.anchorOffset + : Boolean(selection.focusNode.compareDocumentPosition(selection.anchorNode) & Node.DOCUMENT_POSITION_FOLLOWING)); + const target = beforeAnchor ? runs[0] : runs[runs.length - 1]; + const node = target.firstChild; + readerContext.pdfSelectionBoundaryUpdate = true; + try { + selection.setBaseAndExtent(selection.anchorNode, selection.anchorOffset, node, + beforeAnchor ? 0 : node.length); + } finally { + readerContext.pdfSelectionBoundaryUpdate = false; + } + }, { passive: true }); + window.addEventListener("pagehide", (event) => { + readerContext.saveProgress(event); + if (!event.persisted) readerContext.disposeReader(); + }); + window.addEventListener("pageshow", (event) => { + if (event.persisted && !readerContext.readerLifecycle.disposed) { + readerContext.scheduleFoliateScrollSync(); + readerContext.updateProgressTools(); + } + }); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") readerContext.saveProgress(); + if (document.hidden) readerContext.stopPdfManifestPrefetch(); + else readerContext.queuePdfManifestPrefetch(); + }); + window.addEventListener("offline", readerContext.stopPdfManifestPrefetch); + window.addEventListener("online", () => readerContext.queuePdfManifestPrefetch()); + } + + function mountProgressControls() { + readerContext.progressTools.className = "reader-progress-tools"; + readerContext.progressTools.innerHTML = + '
0.0%
'; + document.querySelector("#history-panel").appendChild(readerContext.progressTools); + } + + function mountProgressSummary() { + readerContext.readingProgressSummary.className = "reader-progress-summary"; + readerContext.readingProgressSummary.innerHTML = + '阅读状态0.0%'; + readerContext.progressTools.prepend(readerContext.readingProgressSummary); + readerContext.readingProgressSummary + .querySelector(".reader-progress-bookmark") + .addEventListener("click", (event) => readerContext.openBookmarkPopover(event.currentTarget)); + + readerContext.updateReadingProgressSummary(); + + readerContext.syncTocTab(); + } + + function bindProgressActions() { + readerContext.progressRange.addEventListener("pointerdown", () => { + readerContext.beginReaderNavigation(); + readerContext.progressSeeking = true; + readerContext.previousProgressState = readerContext.captureProgressState(); + }); + readerContext.progressRange.addEventListener("input", () => { + readerContext.progressPercent.textContent = `${Number(readerContext.progressRange.value).toFixed(1)}%`; + readerContext.seekProgress(readerContext.progressRange.value, true); + }); + for (const type of ["pointerup", "pointercancel"]) + readerContext.progressRange.addEventListener(type, () => { + readerContext.progressSeeking = false; + const generation = readerContext.readerRuntime.currentGeneration("navigation"); + readerContext.flushEpubSeek().then(() => { + if (readerContext.isReaderGenerationCurrent("navigation", generation)) readerContext.updateProgressTools(); + }); + }); + readerContext.progressUndo.addEventListener("click", () => { + const state = readerContext.previousProgressState; + readerContext.previousProgressState = null; + readerContext.progressSeeking = false; + readerContext.restoreProgressState(state); + readerContext.updateProgressTools(); + }); + readerContext.chapterPrev.addEventListener("click", () => + readerContext.activateChapter(readerContext.adjacentTocIndex(-1)) + ); + readerContext.chapterNext.addEventListener("click", () => + readerContext.activateChapter(readerContext.adjacentTocIndex(1)) + ); + } + + function ownDomSearchController() { + readerContext.trackReaderResource(readerContext.domSearch.dispose); + } + + function ownSearchViewController() { + readerContext.trackReaderResource(readerContext.fullSearchController.dispose); + } + + function ownPdfSearchStorageAndStart() { + readerContext.trackReaderResource(() => { + readerContext.pdfPersistentTextStore?.dispose(); + readerContext.pdfPersistentTextStore = readerContext.pdfPersistentTextOpening = readerContext.pdfPersistentTextVersion = null; + readerContext.pdfSearchTextCache.clear(); + readerContext.pdfSearchTextCacheBytes = 0; + }); + + readerContext.registerReaderFormatAdapters(); + readerContext.syncCapabilityControls(); + readerContext.updateProgressTools(); + readerContext.start().catch((error) => { + if (readerContext.readerAbortController.signal.aborted || error?.name === "AbortError") return; + console.error(error); + readerContext.fail("阅读文件解析失败,请刷新后重试,或下载原文件。", readerContext.classifyReaderError(error)); + }); + } + + return { + trackStartupOwners, + trackWorkCancellation, + trackEarlySourceResolution, + publishDiagnosticsAndInstallCompatibility, + readSelectedSourceSession, + normalizeConvertedPdfMode, + applySourceSessionAndCachedMetadata, + publishSourceAndSubscribeToStore, + initializePanelAccessibility, + mountInitialLoadingFeedback, + bindDownloadAction, + initializeDocumentTitle, + restoreReturnNavigation, + validateReturnHistory, + ownChapterSearchClient, + ownDocxPageNodes, + ownIndependentPdfResources, + ownPdfBookSearchClient, + ownPdfPrefetchAndQueues, + initializeGenerations, + labelToolbarControls, + applyMetadataAndWatchSanitizedContent, + initializeBookmarkEditor, + initializeBookmarkSearchControls, + mountBookmarkHeaderActions, + mountMediaPanel, + restoreAndBindPageInversion, + restoreReaderThemeAndBindReturn, + bindZoomAndPageControls, + bindReaderUiHistory, + bindPanelBookmarkAndLifecycleControls, + bindPdfPointerSelection, + bindSelectionAndPageLifecycle, + mountProgressControls, + mountProgressSummary, + bindProgressActions, + ownDomSearchController, + ownSearchViewController, + ownPdfSearchStorageAndStart + }; +} diff --git a/static/reader-contract.js b/static/reader/core/reader-contract.js similarity index 100% rename from static/reader-contract.js rename to static/reader/core/reader-contract.js diff --git a/static/reader/core/reader-diagnostics.mjs b/static/reader/core/reader-diagnostics.mjs new file mode 100644 index 0000000000000000000000000000000000000000..40923f695c1bd92147fb33e51ff2f130e1c0d75f --- /dev/null +++ b/static/reader/core/reader-diagnostics.mjs @@ -0,0 +1,22 @@ +export function createReaderDiagnostics(readerContext) { + function pdfStage(stage, page = 0) { + const started = performance.now(); + return (outcome = "ready") => { + if (readerContext.readerAbortController.signal.aborted) return; + readerContext.pdfPageTimings.push({at:started, stage, page, outcome, ms:performance.now()-started}); + if (readerContext.pdfPageTimings.length > 64) readerContext.pdfPageTimings.shift(); + }; + } + + function measurePdf(promise, stage, page = 0) { + const finish = readerContext.pdfStage(stage,page); + return Promise.resolve(promise).then(value => { finish(); return value; }, error => { + finish(error?.name === "AbortError" ? "cancelled" : "failed"); throw error; + }); + } + + return { + pdfStage, + measurePdf + }; +} diff --git a/static/reader/core/reader-document-loader.mjs b/static/reader/core/reader-document-loader.mjs new file mode 100644 index 0000000000000000000000000000000000000000..895405a6e755dd613205253bccd7f612464f225e --- /dev/null +++ b/static/reader/core/reader-document-loader.mjs @@ -0,0 +1,164 @@ +export function createReaderDocumentLoader(readerContext) { + function loadScript(url) { + readerContext.assertReaderActive(); + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + let settled = false, timeout = 0, untrack = () => {}; + const finish = (error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + untrack(); + script.onload = script.onerror = null; + if (error) { + script.remove(); + reject(error); + } else resolve(); + }; + const networkError = () => Object.assign( + new Error("阅读组件下载失败,请检查网络后重试加载,或下载原文件。"), + { code: "READER_ENGINE_NETWORK" } + ); + untrack = readerContext.trackReaderResource(() => finish(readerContext.readerAbortError())); + // Download sibling engines concurrently, execute in dependency order. + script.async = false; + script.src = url; + script.onload = () => finish(); + script.onerror = () => finish(networkError()); + timeout = setTimeout(() => finish(networkError()), 20000); + document.head.appendChild(script); + }); + } + + function fetchWithReaderTimeout(url, timeoutMs = readerContext.READER_PROXY_TIMEOUT_MS, requestInit = {}) { + return readerContext.readerRequestManager.request(url, timeoutMs, requestInit); + } + + function retryableReaderProxyError(error) { + return [408, 429, 500, 502, 503, 504].includes(error?.status) || + ["TypeError", "TimeoutError"].includes(error?.name) || + /failed to fetch|network|timeout|HTTP\s*(?:408|429|5\d\d)|server response\s*\((?:408|429|5\d\d)\)/i.test(error?.message || ""); + } + + async function retryReaderProxy(open) { + for (let attempt = 0; ; attempt++) { + readerContext.assertReaderActive(); + try { + return await open(); + } catch (error) { + if (attempt >= 2 || !readerContext.retryableReaderProxyError(error)) throw error; + await readerContext.waitForReader(350 * (attempt + 1)); + } + } + } + + function fetchReaderUrl(rawUrl, requestInit = {}) { + if (String(rawUrl).includes("/api/reader-bucket-resource?")) + return readerContext.fetchWithReaderTimeout(rawUrl, readerContext.READER_PROXY_TIMEOUT_MS, requestInit); + const proxyUrl = readerContext.readerContentUrl(rawUrl); + return readerContext.retryReaderProxy(async () => { + const response = await readerContext.fetchWithReaderTimeout(proxyUrl, readerContext.READER_PROXY_TIMEOUT_MS, requestInit); + if (response.ok) return response; + await response.body?.cancel(); + throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status }); + }).catch((error) => { + readerContext.assertReaderActive(); + requestInit.signal?.throwIfAborted(); + if (readerContext.retryableReaderProxyError(error)) throw error; + return readerContext.fetchWithReaderTimeout(rawUrl, readerContext.READER_PROXY_TIMEOUT_MS, requestInit); + }); + } + + function fetchReaderResponse() { + const early = window.__VOICE_PDF_PRELOAD__; + if (early?.manifest && early.manifestUrl === new URL(readerContext.sourceUrl, location.href).href) { + const pending = early.manifest; + early.manifest = null; + return pending.then( + (response) => (response?.ok ? response : readerContext.fetchReaderUrl(readerContext.sourceUrl)), + () => readerContext.fetchReaderUrl(readerContext.sourceUrl) + ); + } + return readerContext.fetchReaderUrl(readerContext.sourceUrl); + } + + function prepareReaderDocument(engineTasks) { + const engines = Promise.all(engineTasks); + const response = Promise.race([readerContext.fetchReaderResponse(), engines.then(() => new Promise(() => {}))]); + // Rendering owns both promises; early rejection must not become unhandled. + response.catch(() => {}); + engines.catch(() => {}); + return {response, engines}; + } + + function readPreparedBytes(response, engines, limit = VoiceOfMLReaderSecurity.LIMITS.documentBytes) { + return Promise.all([Promise.resolve(response).then(response => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return VoiceOfMLReaderSecurity.readBytes(response,limit); + }), engines]).then(([bytes]) => bytes); + } + + function loadTextDocument() { + return readerContext.fetchReaderResponse().then((response) => ({ response })); + } + + function loadMarkdownDocument() { + return readerContext.prepareReaderDocument([readerContext.loadScript(readerContext.MARKED_URL), readerContext.loadScript(readerContext.PURIFY_URL)]); + } + + function loadHtmlDocument() { + const {response, engines} = readerContext.prepareReaderDocument([readerContext.loadScript(readerContext.PURIFY_URL)]); + return {response, engine:engines}; + } + + function loadDocxDocument() { + return readerContext.prepareReaderDocument([readerContext.loadScript(readerContext.JSZIP_URL), readerContext.loadScript(readerContext.DOCX_PREVIEW_URL)]); + } + + function loadMediaDocument() { + return Promise.resolve(null); + } + + async function loadImageDocument(target = readerContext.sourceUrl) { + const deadline = performance.now() + readerContext.READER_PROXY_TIMEOUT_MS; + const image = new Image(); + image.className = "reader-image"; + image.alt = readerContext.documentState.title; + image.decoding = "async"; + try { + return await readerContext.imageLoader.load(image, readerContext.readerContentUrl(target), readerContext.readerAbortController.signal, deadline); + } catch (error) { + readerContext.assertReaderActive(); + if (error?.code === "READER_RESOURCE_LIMIT") throw error; + return readerContext.imageLoader.load(image, target, readerContext.readerAbortController.signal, deadline); + } + } + + function loadChapterManifestDocument() { + const target = readerContext.chapterManifestUrl || readerContext.sourceUrl, early = window.__VOICE_READER_CHAPTER_PRELOAD__; + if (early?.manifest && early.manifestUrl === new URL(target, location.href).href) { + const pending = early.manifest; + early.manifest = null; + return pending.then(response => response.ok ? response : readerContext.fetchReaderUrl(target), () => readerContext.fetchReaderUrl(target)); + } + return readerContext.fetchReaderUrl(target); + } + + return { + loadScript, + fetchWithReaderTimeout, + retryableReaderProxyError, + retryReaderProxy, + fetchReaderUrl, + fetchReaderResponse, + prepareReaderDocument, + readPreparedBytes, + loadTextDocument, + loadMarkdownDocument, + loadHtmlDocument, + loadDocxDocument, + loadMediaDocument, + loadImageDocument, + loadChapterManifestDocument + }; +} diff --git a/static/reader/core/reader-lifecycle.mjs b/static/reader/core/reader-lifecycle.mjs new file mode 100644 index 0000000000000000000000000000000000000000..023fe15ae17007371c0aff854155d7bdd0d5ec42 --- /dev/null +++ b/static/reader/core/reader-lifecycle.mjs @@ -0,0 +1,170 @@ +export function createReaderLifecycle(readerContext) { + function readerAbortError() { + return new DOMException("Reader work cancelled", "AbortError"); + } + + function assertReaderActive() { + if (readerContext.readerAbortController.signal.aborted || readerContext.readerRuntime.state.lifecycle.disposed) + throw readerContext.readerAbortError(); + } + + function trackReaderResource(cleanup) { + if (readerContext.readerAbortController.signal.aborted) cleanup(); + else readerContext.readerResources.add(cleanup); + return () => readerContext.readerResources.delete(cleanup); + } + + function stopReaderWork() { + readerContext.readerAbortController.abort(); + for (const cleanup of readerContext.readerResources) { + try { + cleanup(); + } catch (_) {} + } + readerContext.readerResources.clear(); + } + + function awaitReader(promise, extraSignal = null) { + return new Promise((resolve, reject) => { + let settled = false; + const signals = extraSignal ? [readerContext.readerAbortController.signal, extraSignal] : [readerContext.readerAbortController.signal]; + const finish = (callback, value) => { + if (settled) return; + settled = true; + signals.forEach(signal => signal.removeEventListener("abort", abort)); + callback(value); + }; + const abort = () => finish(reject, readerContext.readerAbortError()); + Promise.resolve(promise) + .then(value => finish(resolve, value), error => finish(reject, error)); + if (signals.some(signal => signal.aborted)) abort(); + else signals.forEach(signal => signal.addEventListener("abort", abort, { once: true })); + }); + } + + function waitForReader(delay = 0, animationFrame = false) { + return new Promise((resolve, reject) => { + readerContext.assertReaderActive(); + const signal = readerContext.readerAbortController.signal; + const abort = () => { + if (animationFrame) cancelAnimationFrame(handle); + else clearTimeout(handle); + reject(readerContext.readerAbortError()); + }; + const done = () => { + signal.removeEventListener("abort", abort); + resolve(); + }; + const handle = animationFrame ? requestAnimationFrame(done) : setTimeout(done, delay); + signal.addEventListener("abort", abort, { once: true }); + }); + } + + function setReaderPhase(phase) { + return readerContext.readerRuntime.setPhase(phase); + } + + function setReaderStage(stage) { + return readerContext.readerRuntime.setStage(stage); + } + + function classifyReaderError(error, fallback = "READER_PARSE") { + if (["READER_ENGINE_NETWORK", "READER_RESTORE"].includes(error?.code)) return error.code; + const value = `${error?.name || ""} ${error?.message || error || ""}`; + if (/AbortError|timeout|network|fetch|HTTP\s*\d+/i.test(value)) return "READER_NETWORK"; + if ( + /EPUB_INVALID|corrupt|damage|truncated|central directory|end of data|invalid zip/i.test(value) + ) + return "READER_CORRUPT"; + return fallback; + } + + function markReaderContentReady() { + if (["foliate", "epub-chapters"].includes(readerContext.capability?.mode) && readerContext.loadingIndicator.isConnected) + readerContext.foliateScrollAnchors.preserve(() => readerContext.loadingIndicator.remove()); + else readerContext.loadingIndicator.remove(); + readerContext.loadingStatus.hidden = true; + } + + function nextReaderGeneration(name) { + const generation = readerContext.readerRuntime.nextGeneration(name); + if (name === "search") readerContext.drainPdfTextWaiters(); + return generation; + } + + function isReaderGenerationCurrent(name, value) { + return !readerContext.readerAbortController.signal.aborted && readerContext.readerRuntime.isCurrent(name, value); + } + + function reportNavigationError(error, generation) { + if (readerContext.isReaderGenerationCurrent("navigation", generation) && error?.name !== "AbortError") + console.warn("Reader navigation failed", error); + } + + function disposeReader() { + if (readerContext.readerLifecycle.disposed) return; + readerContext.updateDocumentState({ restorationReady: false }); + readerContext.readerRuntime.dispose(); + readerContext.contentSanitizerObserver?.disconnect(); + readerContext.contentSanitizerObserver = null; + clearTimeout(readerContext.saveTimer); + clearTimeout(readerContext.themeAnimationTimer); + clearTimeout(readerContext.panelAnimationTimer); + for (const frame of [readerContext.markerFrame, readerContext.foliateScrollFrame, readerContext.foliateWindowFrame, readerContext.epubSeekFrame]) + if (frame) cancelAnimationFrame(frame); + readerContext.saveTimer = + readerContext.themeAnimationTimer = + readerContext.panelAnimationTimer = + readerContext.markerFrame = + readerContext.foliateScrollFrame = + readerContext.foliateWindowFrame = + readerContext.epubSeekFrame = + 0; + VoiceOfMLReaderStore.dispose?.(); + } + + function fail(message, code = "READER_PARSE") { + if (readerContext.readerLifecycle.disposed || readerContext.readerAbortController.signal.aborted) return; + readerContext.updateDocumentState({ restorationReady: false }); + readerContext.readerRuntime.fail(code); + readerContext.stopReaderWork(); + readerContext.readerRequestManager.dispose(); + readerContext.formatAdapters.dispose(); + window.__VOICE_PDF_PRELOAD__?.dispose(); + readerContext.content.dataset.errorCode = code; + readerContext.loadingStatus.hidden = true; + readerContext.markReaderContentReady(); + const visibleMessage = `${message} [${readerContext.readerLifecycle.stage}]`; + readerContext.content.innerHTML = `
`; + readerContext.content.querySelector(".reader-error").textContent = visibleMessage; + if (["READER_ENGINE_NETWORK", "READER_RESTORE"].includes(code)) { + const retry = document.createElement("button"); + retry.type = "button"; + retry.className = "text-button"; + retry.id = "reader-engine-retry"; + retry.textContent = "重试加载"; + // Reload clears the browser's failed module map and preserves this book URL. + retry.addEventListener("click", () => location.reload()); + readerContext.content.querySelector(".reader-error").append(document.createElement("br"), retry); + } + readerContext.status.textContent = "无法打开"; + } + + return { + readerAbortError, + assertReaderActive, + trackReaderResource, + stopReaderWork, + awaitReader, + waitForReader, + setReaderPhase, + setReaderStage, + classifyReaderError, + markReaderContentReady, + nextReaderGeneration, + isReaderGenerationCurrent, + reportNavigationError, + disposeReader, + fail + }; +} diff --git a/static/reader-request-manager.js b/static/reader/core/reader-request-manager.js similarity index 100% rename from static/reader-request-manager.js rename to static/reader/core/reader-request-manager.js diff --git a/static/reader-runtime.js b/static/reader/core/reader-runtime.js similarity index 100% rename from static/reader-runtime.js rename to static/reader/core/reader-runtime.js diff --git a/static/reader-security.js b/static/reader/core/reader-security.js similarity index 100% rename from static/reader-security.js rename to static/reader/core/reader-security.js diff --git a/static/reader/core/reader-source.mjs b/static/reader/core/reader-source.mjs new file mode 100644 index 0000000000000000000000000000000000000000..c103c759ddc2dcde38cb66fcafa336557532eef9 --- /dev/null +++ b/static/reader/core/reader-source.mjs @@ -0,0 +1,335 @@ +export function createReaderSource(readerContext) { + function readerContentUrl(url) { + try { + const parsed = new URL(url, location.href); + if (parsed.origin === location.origin && parsed.pathname === "/api/reader-bucket-resource") + return parsed.href; + if (parsed.origin === "https://huggingface.co" && !parsed.username && !parsed.password && !parsed.search && !parsed.hash) { + const prefix = /^\/buckets\/(vomebook\/(?:pdf-pages-v2|reader-assets-v2))\/resolve\//.exec(parsed.pathname); + const path = prefix && decodeURIComponent(parsed.pathname.slice(prefix[0].length)); + if (path && VoiceOfMLReader.isBucketPath(path, true, prefix[1])) + return `/api/reader-bucket-resource?path=${encodeURIComponent(path)}`; + } + } catch (_) {} + return `/api/reader-content?url=${encodeURIComponent(url)}`; + } + + function readerDownloadApiUrl(path) { + return path; + } + + function readerDownloadUrl(filename, link) { + return readerContext.readerDownloadApiUrl(`/api/download?file=${encodeURIComponent(filename || "file")}&link=${encodeURIComponent(link || "")}`); + } + + async function startReaderDownload(event) { + event.preventDefault(); + const button = event.currentTarget; + if (button.hasAttribute("aria-busy")) return; + readerContext.showDownloadFeedback("正在准备下载…", 0); + const controller = new AbortController(); + let timer; + button.setAttribute("aria-busy", "true"); + try { + // ID-only links share the Reader's in-flight resolution, not its render phase. + if (!readerContext.downloadUrl) await readerContext.readerSourcePromise; + if (!readerContext.downloadUrl) throw new Error("无法获取原文件地址"); + const filename = readerContext.documentState.title || "file"; + const checkUrl = readerContext.readerDownloadApiUrl(`/api/download/check?link=${encodeURIComponent(readerContext.downloadUrl)}`); + timer = setTimeout(() => controller.abort(), 8000); + const response = await fetch(checkUrl, { signal: controller.signal, cache: "no-store" }); + const result = await response.json().catch(() => null); + if (!response.ok || result?.ok !== true) throw new Error(result?.error || "下载检查失败,请重试"); + if (readerContext.readerLifecycle.disposed) return; + const frame = document.createElement("iframe"); + frame.hidden = true; + frame.setAttribute("aria-hidden", "true"); + frame.src = readerContext.readerDownloadUrl(filename, readerContext.downloadUrl); + document.body.appendChild(frame); + setTimeout(() => frame.remove(), 60000); + readerContext.showDownloadFeedback("已发起下载"); + } catch (error) { + if (!readerContext.readerLifecycle.disposed) { + readerContext.showDownloadFeedback(error?.name === "AbortError" ? "下载检查超时,请重试" : (error.message || "下载失败,请重试")); + } + } finally { + clearTimeout(timer); + button.removeAttribute("aria-busy"); + } + } + + function normalizeSourceUrl(url) { + return url.startsWith("/api/reader-bucket-resource?") ? new URL(url, location.origin).href : url; + } + + async function resolveReaderId(id) { + const endpoint = `/api/reader-resolve?id=${encodeURIComponent(id)}`; + for (let attempt = 0; attempt < 2; attempt++) { + const early = window.__VOICE_READER_RESOLVE__; + if (!attempt && early?.id === id) { + window.__VOICE_READER_RESOLVE__ = null; + const untrack = readerContext.trackReaderResource(early.dispose); + let result; + try { result = await readerContext.awaitReader(early.promise); } + finally { untrack(); early.dispose(); } + if (result.ok) return result.data; + if (![408, 429, 500, 502, 503, 504].includes(result.status)) + throw new Error(`HTTP ${result.status}`); + await readerContext.waitForReader(350); + continue; + } + const response = await readerContext.readerRequestManager.request(endpoint, readerContext.READER_PROXY_TIMEOUT_MS); + if (response.ok) return response.json(); + if (![408, 429, 500, 502, 503, 504].includes(response.status) || attempt) + throw new Error(`HTTP ${response.status}`); + await response.body?.cancel(); + await readerContext.waitForReader(350); + } + throw new Error("Reader ID resolution failed"); + } + + function applyReaderMetadata(data) { + if (data) readerContext.resolvedReaderData = { ...(readerContext.resolvedReaderData || {}), ...data }; + if (data?.ReaderOcrManifest || data?.ocr_manifest) { + readerContext.ocrManifestUrl = data.ReaderOcrManifest || data.ocr_manifest; + readerContext.params.set("ocr_manifest", readerContext.ocrManifestUrl); + } + readerContext.readerRuntime.update("source", { metadata: readerContext.resolvedReaderData }); + const title = String(readerContext.resolvedReaderData?.title || readerContext.params.get("title") || readerContext.sourceName).trim(); + const suffix = String( + readerContext.resolvedReaderData?.original_extension || (readerContext.extension === "pdf-pages" ? "pdf" : readerContext.extension) + ) + .toLowerCase() + .replace(/^\./, ""); + const value = title + (suffix && !title.toLowerCase().endsWith(`.${suffix}`) ? `.${suffix}` : ""); + readerContext.updateDocumentState({ title: value }); + document.querySelector("#title").textContent = value; + document.title = `${value} - VoiceOfML Reader`; + readerContext.applyReaderPathMetadata(readerContext.resolvedReaderData); + } + + function applyReaderPathMetadata(data) { + const folder = Array.isArray(data?.folder) ? data.folder.join("/") : String(data?.folder || ""); + const value = String( + data?.path || [data?.repo, folder].filter(Boolean).join("/") || readerContext.readerPathLabel + ).replace(/^\/+|\/+$/g, ""); + readerContext.folderNavigationTarget = null; + try { + const target = new URL(readerContext.folderReturnUrl, location.origin); + if ( + readerContext.folderReturnUrl && + target.origin === location.origin && + target.pathname.split("/").filter(Boolean).length === 1 + ) + readerContext.folderNavigationTarget = target; + } catch (_) {} + if (!readerContext.folderNavigationTarget && data?.repo) { + readerContext.folderNavigationTarget = new URL("/" + encodeURIComponent(data.repo), location.origin); + if (folder) readerContext.folderNavigationTarget.searchParams.set("folder_self", folder); + } + readerContext.readerPath.textContent = value; + readerContext.readerPath.hidden = !value; + readerContext.readerPath.disabled = !readerContext.folderNavigationTarget; + readerContext.readerPath.setAttribute("aria-label", `筛选文件夹:${value}`); + } + + function validSource(raw) { + try { + const url = new URL(raw); + const bucketPath = url.searchParams.get("path") || ""; + if ( + (url.origin === location.origin || url.origin === "https://voiceofml-search.hf.space") && + url.pathname === "/api/reader-bucket-resource" && + url.searchParams.getAll("path").length === 1 && + [...url.searchParams.keys()].every((key) => key === "path") && + (VoiceOfMLReader.isBucketPath(bucketPath, true, "vomebook/pdf-pages-v2") || + VoiceOfMLReader.isBucketPath(bucketPath, true, "vomebook/reader-assets-v2")) + ) + return true; + if (url.protocol === "https:" && url.hostname === "huggingface.co" && + (url.pathname.startsWith("/buckets/vomebook/pdf-pages-v2/resolve/") || + url.pathname.startsWith("/buckets/vomebook/reader-assets-v2/resolve/")) && !url.search && !url.hash) { + const prefix = url.pathname.startsWith("/buckets/vomebook/pdf-pages-v2/resolve/") + ? "/buckets/vomebook/pdf-pages-v2/resolve/" + : "/buckets/vomebook/reader-assets-v2/resolve/"; + const path = decodeURIComponent(url.pathname.slice(prefix.length)); + const bucket = prefix.includes("pdf-pages-v2") ? "vomebook/pdf-pages-v2" : "vomebook/reader-assets-v2"; + return VoiceOfMLReader.isBucketPath(path, true, bucket); + } + if (url.protocol !== "https:" || !["huggingface.co", "hf-mirror.com"].includes(url.hostname)) + return false; + const readerAsset = VoiceOfMLReader.isAssetSourcePath(url.pathname); + if (readerContext.extension === "docx") return readerAsset; + return /^\/datasets\/VoiceOfML\/[^/]+\/(resolve|raw)\//.test(url.pathname) || readerAsset; + } catch (_) { + return false; + } + } + + function validOcr(raw) { + try { + const url = new URL(raw, location.origin); + return url.origin === location.origin && url.pathname.startsWith("/txt/"); + } catch (_) { + return false; + } + } + + function validFallback(raw) { + try { + const url = new URL(raw); + return ( + url.protocol === "https:" && + ["huggingface.co", "hf-mirror.com"].includes(url.hostname) && + /\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:linearized\.pdf|(?:[a-z0-9-]+\/)?document\.pdf)$/.test( + url.pathname + ) + ); + } catch (_) { + return false; + } + } + + function readerAssetObjectFamily(raw, base = location.href) { + try { + const url = new URL(raw, base); + if (url.origin === "https://voiceofml-search.hf.space" && url.pathname === "/api/reader-bucket-resource") { + const bucketPath = url.searchParams.get("path") || ""; + const match = bucketPath.match(/^(chapters\/ebook\/(?:epub|mobi|azw3|fb2|chm)\/[0-9a-f]{64}\/[0-9a-f]{16}\/)/); + if (url.hash || url.searchParams.getAll("path").length !== 1 || + [...url.searchParams.keys()].some((key) => key !== "path") || !match || + !VoiceOfMLReader.isBucketPath(bucketPath, true, "vomebook/reader-assets-v2")) return null; + return { url, prefix: match[1], bucket: true, bucketPath }; + } + if ( + url.protocol !== "https:" || + !["huggingface.co", "hf-mirror.com"].includes(url.hostname) || + url.username || + url.password + ) + return null; + const match = url.pathname.match( + /^\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/(objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[a-z0-9-]+\/)?)/ + ); + return match + ? { url, prefix: url.pathname.slice(0, url.pathname.indexOf(match[1])) + match[1], bucket: false } + : null; + } catch (_) { + return null; + } + } + + function trustedReaderAssetManifestUrl(raw) { + const family = readerContext.readerAssetObjectFamily(raw); + const manifestPath = family?.bucket ? family.bucketPath : family?.url.pathname; + return family && + (family.bucket || !family.url.search) && + (family.bucket || !family.url.hash) && + /\/chapter-manifest\.json$/i.test(manifestPath || "") + ? family.url.href + : null; + } + + function trustedChapterUrl(raw, base) { + const manifest = readerContext.readerAssetObjectFamily(base); + const manifestPath = manifest?.bucket ? manifest.bucketPath : new URL(base, location.href).pathname; + if (!manifest || !/\/chapter-manifest\.json$/i.test(manifestPath)) + return null; + try { + if (manifest.bucket) { + const url = new URL(String(raw || ""), `https://bucket.invalid/${manifest.bucketPath}`); + const path = decodeURIComponent(url.pathname.slice(1)); + if (url.origin !== "https://bucket.invalid" || url.search || url.hash || + !path.startsWith(manifest.prefix) || + !/(?:\/chapters\/chapter-[0-9]{4}\.xhtml|\/epub-search-index\.json\.gz)$/i.test(path) || + !VoiceOfMLReader.isBucketPath(path, true, "vomebook/reader-assets-v2")) return null; + return `${manifest.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`; + } + const url = new URL(raw, base); + return !url.search && + !url.hash && + url.origin === manifest.url.origin && + url.pathname.startsWith(manifest.prefix) && + /(?:\.(?:xhtml?|html?)|\/epub-search-index\.json\.gz)$/i.test(url.pathname) + ? url.href + : null; + } catch (_) { + return null; + } + } + + function trustedChapterReferenceUrl(raw, base, manifestBase) { + const family = readerContext.readerAssetObjectFamily(manifestBase); + if (!family) return null; + try { + if (family.bucket) { + const current = readerContext.readerAssetObjectFamily(base); + if (!current?.bucket) return null; + const url = new URL(String(raw || ""), `https://bucket.invalid/${current.bucketPath}`); + const path = decodeURIComponent(url.pathname.slice(1)); + if (url.origin !== "https://bucket.invalid" || url.search || + !path.startsWith(family.prefix) || !/\/chapters\/chapter-[0-9]{4}\.xhtml$/i.test(path) || + !VoiceOfMLReader.isBucketPath(path, true, "vomebook/reader-assets-v2")) return null; + return { url: `${family.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`, + fragment: url.hash }; + } + const url = new URL(raw, base); + return !url.search && url.origin === family.url.origin && url.pathname.startsWith(family.prefix) && + /\/(?:chapters\/)?[^/]+\.xhtml?$/i.test(url.pathname) + ? { url: url.href, fragment: url.hash } : null; + } catch (_) { + return null; + } + } + + function trustedChapterResourceUrl(raw, base, attribute, manifestBase) { + const value = String(raw || "").trim(); + if (!value || /^(?:data:|mailto:|tel:|javascript:)/i.test(value)) return null; + // Chapters and resources can be siblings within the manifest's asset directory. + const family = readerContext.readerAssetObjectFamily(manifestBase); + if (!family) return null; + try { + if (family.bucket) { + const current = readerContext.readerAssetObjectFamily(base); + if (!current?.bucket) return null; + const url = new URL(value, `https://bucket.invalid/${current.bucketPath}`); + const path = decodeURIComponent(url.pathname.slice(1)); + if (url.origin !== "https://bucket.invalid" || url.search || url.hash || + !path.startsWith(family.prefix) || !VoiceOfMLReader.isBucketPath(path, true, "vomebook/reader-assets-v2")) return null; + const resource = `${family.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`; + return attribute === "href" ? resource : resource; + } + const url = new URL(value, base), + path = url.pathname.toLowerCase(); + if (url.origin !== family.url.origin || !url.pathname.startsWith(family.prefix)) return null; + const allowed = + attribute === "href" + ? /\.(?:x?html?|css|svg|png|jpe?g|gif|webp|woff2?|ttf|otf|mp3|mp4|m4a|webm|ogg)$/i.test( + path + ) + : /\.(?:svg|png|jpe?g|gif|webp|woff2?|ttf|otf|mp3|mp4|m4a|webm|ogg)$/i.test(path); + return allowed ? url.href : null; + } catch (_) { + return null; + } + } + + return { + readerContentUrl, + readerDownloadApiUrl, + readerDownloadUrl, + startReaderDownload, + normalizeSourceUrl, + resolveReaderId, + applyReaderMetadata, + applyReaderPathMetadata, + validSource, + validOcr, + validFallback, + readerAssetObjectFamily, + trustedReaderAssetManifestUrl, + trustedChapterUrl, + trustedChapterReferenceUrl, + trustedChapterResourceUrl + }; +} diff --git a/static/reader-store.js b/static/reader/core/reader-store.js similarity index 100% rename from static/reader-store.js rename to static/reader/core/reader-store.js diff --git a/static/reader/formats/reader-chapter-format.mjs b/static/reader/formats/reader-chapter-format.mjs new file mode 100644 index 0000000000000000000000000000000000000000..c37d111f8c86101cb0523591212438fa67e4dff6 --- /dev/null +++ b/static/reader/formats/reader-chapter-format.mjs @@ -0,0 +1,450 @@ +export function createReaderChapterFormat(readerContext) { + async function renderChapterManifest(prepared) { + const response = await prepared; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const manifest = VoiceOfMLReaderSecurity.validateChapterManifest( + JSON.parse( + await VoiceOfMLReaderSecurity.readText(response, VoiceOfMLReaderSecurity.LIMITS.manifestBytes) + ) + ); + readerContext.assertReaderActive(); + const frame = document.createElement("div"); + frame.className = "epub-frame"; + frame.style.overflowAnchor = "none"; + const openingGeneration = readerContext.readerRuntime.currentGeneration("navigation"); + readerContext.content.appendChild(frame); + const loaded = new Set(), retainedBytes = new Map(), + manifestBase = readerContext.trustedReaderAssetManifestUrl(readerContext.chapterManifestUrl || readerContext.sourceUrl); + const chapterByIndex = new Map(manifest.chapters.map((chapter) => [chapter.index, chapter])); + const chapterPrefetchCount = 6; + const chapterPrefetchMaxCount = 10, chapterPrefetchBytes = 128 * 1024; + let chapterFocusIndex = 1, chapterWindowFrame = 0, chapterWindowReady = false; + let chapterNavigating = 0; + let chapterPreviousObserver = null; + const chapterBudget = VoiceOfMLReaderSecurity.createByteBudget(); + if (!manifestBase) throw new Error("EPUB_INVALID_MANIFEST_PATH"); + const searchIndex = manifest.search_index; + readerContext.chapterSearchConfiguration = + searchIndex?.path === "epub-search-index.json.gz" && + Number.isSafeInteger(searchIndex.bytes) && + searchIndex.bytes > 0 && + /^[0-9a-f]{64}$/.test(searchIndex.sha256 || "") + ? { + chapters: manifest.chapters, + index: { + ...searchIndex, + url: (() => { + const resolved = readerContext.trustedChapterUrl(searchIndex.path, manifestBase); + if (!resolved) return ""; + const target = new URL(resolved, location.href); + const bucketUrl = target.origin === "https://voiceofml-search.hf.space" && + target.pathname === "/api/reader-bucket-resource" + ? target.pathname + target.search : resolved; + return readerContext.readerContentUrl(bucketUrl); + })() + } + } + : null; + const chapterUrl = (chapter) => readerContext.trustedChapterUrl(chapter.path, manifestBase); + const chapterAt = (index) => chapterByIndex.get(index) || null; + const nextChapterNode = (index, selector) => { + for (const node of frame.querySelectorAll(selector)) + if (Number(node.dataset.chapter) > index) return node; + return null; + }; + const resourceUrl = (raw, base, attribute) => { + const value = String(raw || "").trim(); + if (!value || value.startsWith("#")) return null; + return readerContext.trustedChapterResourceUrl(value, base, attribute, manifestBase); + }; + const insertChapter = (article) => { + const existing = frame.querySelector( + `.reader-epub-chapter[data-chapter="${article.dataset.chapter}"]` + ); + if (existing) return existing; + const next = nextChapterNode(Number(article.dataset.chapter), ".reader-epub-chapter, .reader-chapter-sentinel"); + const marker = frame.querySelector(`.reader-chapter-sentinel[data-chapter="${article.dataset.chapter}"]`); + const insert = () => { + if (marker) { + readerContext.chapterManifestObserver.unobserve(marker); + chapterPreviousObserver.unobserve(marker); + marker.replaceWith(article); + } else frame.insertBefore(article, next || null); + }; + const preservePosition = chapterWindowReady || !readerContext.isReaderGenerationCurrent("navigation", openingGeneration); + if (preservePosition) readerContext.foliateScrollAnchors.preserve(insert); + else insert(); + readerContext.chapterManifestObserver.observe(article); + if (preservePosition) readerContext.foliateScrollAnchors.observe(article); + return article; + }; + const ensureSentinel = (chapter, previous = false) => { + if (readerContext.readerAbortController.signal.aborted || loaded.has(chapter.index)) return null; + const selector = `.reader-chapter-sentinel[data-chapter="${chapter.index}"]`; + const existing = frame.querySelector(selector); + if (existing) return existing; + const marker = document.createElement("button"); + marker.type = "button"; + marker.className = "reader-chapter-sentinel"; + marker.dataset.chapter = String(chapter.index); + marker.textContent = `加载第 ${chapter.index} 章`; + marker.setAttribute("aria-label", `重试加载第 ${chapter.index} 章`); + marker.addEventListener("click", () => { + marker.disabled = true; + fetchChapter(chapter).catch((error) => { + marker.disabled = false; + console.warn("EPUB chapter could not be loaded", error); + }); + }); + const next = nextChapterNode(chapter.index, + ".reader-epub-chapter[data-chapter], .reader-chapter-sentinel[data-chapter]"); + const insert = () => frame.insertBefore(marker, next || null); + if (chapterWindowReady || !readerContext.isReaderGenerationCurrent("navigation", openingGeneration)) + readerContext.foliateScrollAnchors.preserve(insert); + else insert(); + (previous ? chapterPreviousObserver : readerContext.chapterManifestObserver).observe(marker); + return marker; + }; + const chapterTarget = (article, fragment) => + !fragment || article.id === fragment ? article : + article.querySelector(`[id="${CSS.escape(fragment)}"], [name="${CSS.escape(fragment)}"]`); + const setChapterToc = () => + readerContext.setToc( + (manifest.toc ?? manifest.chapters.map((item) => ({ + title: item.title || `章节 ${item.index}`, chapter: item.index, depth: 0, fragment: "" + }))).map((item) => ({ + label: item.title, + chapterIndex: item.chapter, + fragment: item.fragment, + depth: item.depth, + activate: (generation) => activateChapter(manifest.chapters[item.chapter - 1], generation, item.fragment) + })) + ); + const chapterScheduler = VoiceOfMLReaderChapters.createChapterScheduler({ + create: async (index, signal, priority) => { + const chapter = chapterAt(index); + readerContext.assertReaderActive(); + const url = chapterUrl(chapter); + if (!url) throw new Error("EPUB_INVALID_PATH"); + const result = await readerContext.fetchReaderUrl(url, { signal, priority }); + if (!result.ok) throw new Error(`HTTP ${result.status}`); + const bytes = await VoiceOfMLReaderSecurity.readBytes( + result, + VoiceOfMLReaderSecurity.LIMITS.chapterBytes, + chapterBudget + ); + let inserted = false; + try { + readerContext.assertReaderActive(); + signal.throwIfAborted(); + const doc = readerContext.parseChapterDocument(new TextDecoder().decode(bytes)); + for (const element of doc.querySelectorAll("*")) { + for (const name of ["src", "href", "poster", "xlink:href"]) + if (element.hasAttribute(name)) { + const value = element.getAttribute(name) || ""; + if (name === "href" && element.localName === "a" && + readerContext.trustedChapterReferenceUrl(value, url, manifestBase)) continue; + const resolved = resourceUrl(value, url, name === "href" ? "href" : "resource"); + if (resolved) element.setAttribute(name, resolved); + else if (!(name === "href" && value.startsWith("#"))) element.removeAttribute(name); + } + if (element.hasAttribute("srcset")) element.removeAttribute("srcset"); + } + const article = document.createElement("article"); + article.className = "reader-markdown reader-epub-chapter"; + article.dataset.chapter = String(chapter.index); + if (doc.body?.id) article.id = doc.body.id; + article.innerHTML = doc.body ? doc.body.innerHTML : ""; + for (const image of article.querySelectorAll("img")) { + image.loading = priority === "high" ? "eager" : "lazy"; + image.fetchPriority = priority; + image.decoding = "async"; + } + // The generated text index includes head text (e.g. the chapter title). + article._chapterSearchHead = doc.head ? document.importNode(doc.head, true) : null; + insertChapter(article); + loaded.add(chapter.index); + retainedBytes.set(chapter.index, bytes.byteLength); + inserted = true; + for (const entry of readerContext.navigationState.tocEntries) + if (entry.chapterIndex === chapter.index) + entry.target = chapterTarget(article, entry.fragment); + const next = chapterAt(chapter.index + 1); + if (next) ensureSentinel(next); + const previous = chapterAt(chapter.index - 1); + if (previous) ensureSentinel(previous, true); + scheduleChapterWindow(); + } catch (error) { + if (!inserted) chapterBudget.release(bytes.byteLength); + throw error; + } + } + }); + const fetchChapter = (chapter, demand = true, exclusive = false) => { + readerContext.assertReaderActive(); + if (loaded.has(chapter.index)) return Promise.resolve(); + return chapterScheduler.load(chapter.index, demand, exclusive).then(() => { + readerContext.foliateScrollAnchors.whenIdle(trimChapterWindow); + }).catch(error => { + if (error.name !== "AbortError" && !readerContext.readerAbortController.signal.aborted) { + const marker = ensureSentinel(chapter); + marker?.classList.add("reader-chapter-load-error"); + marker?.removeAttribute("aria-hidden"); + } + throw error; + }); + }; + const activateChapter = async (chapter, generation, fragment = "") => { + chapterFocusIndex = chapter.index; + chapterNavigating++; + try { await fetchChapter(chapter, true, chapterWindowReady); } + finally { chapterNavigating--; } + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + const node = frame.querySelector(`.reader-epub-chapter[data-chapter="${chapter.index}"]`); + let id = fragment.replace(/^#/, ""); + try { + id = decodeURIComponent(id); + } catch (_) {} + const anchor = chapterTarget(node, id); + if (!anchor) throw new Error("EPUB_MISSING_ANCHOR"); + readerContext.foliateScrollAnchors.invalidate(); + anchor.scrollIntoView({ block: "start" }); + readerContext.foliateScrollAnchors.remember(); + readerContext.syncHeadingLocation(); + readerContext.updateProgressTools(); + readerContext.scheduleSave(); + return true; + }; + frame.addEventListener("click", async (event) => { + const link = event.target.closest?.("a[href]"); + const article = link?.closest(".reader-epub-chapter"); + if ( + !article || + event.button !== 0 || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey + ) + return; + const current = chapterAt(Number(article.dataset.chapter)); + const destination = readerContext.trustedChapterReferenceUrl(link.getAttribute("href"), chapterUrl(current), manifestBase); + const target = destination ? new URL(destination.url) : null; + const fragment = destination?.fragment || ""; + const chapter = target && manifest.chapters.find((item) => chapterUrl(item) === target.href); + if (!chapter) { + // A stale or missing book document must not navigate out to an asset 404. + if (/\.(?:xhtml?|html?)(?:$|#)/i.test(link.getAttribute("href") || "")) { + event.preventDefault(); + let message = link.nextElementSibling; + if (!message?.classList.contains("reader-chapter-link-error")) { + message = document.createElement("span"); + message.className = "reader-chapter-link-error"; + message.setAttribute("role", "status"); + message.textContent = "(无法定位此书内链接)"; + link.after(message); + } + } + return; + } + event.preventDefault(); + const generation = readerContext.beginReaderNavigation(); + try { + await activateChapter(chapter, generation, fragment); + } catch (error) { + readerContext.reportNavigationError(error, generation); + } + }); + const chapterVirtualizer = VoiceOfMLReaderVirtual.createSectionVirtualizer({ + limit: 12, + getLoaded: () => [...frame.querySelectorAll(":scope > .reader-epub-chapter")], + getIndex: article => Number(article.dataset.chapter), + getHeight: article => article.getBoundingClientRect().height, + canVirtualize: article => { + const rect = article.getBoundingClientRect(), view = readerContext.viewport.getBoundingClientRect(); + const selection = window.getSelection(); + return (rect.bottom < view.top - 800 || rect.top > view.bottom + 800) && + !article.contains(document.activeElement) && + !article.querySelector("mark.full-search-highlight") && + !(selection && !selection.isCollapsed && + selection.containsNode(article, true)); + }, + virtualize: (article, index, height) => { + loaded.delete(index); + const marker = ensureSentinel(chapterAt(index)); + const style = getComputedStyle(article); + marker.classList.add("reader-chapter-placeholder"); + marker.style.height = `${height}px`; + marker.style.marginTop = style.marginTop; + marker.style.marginBottom = style.marginBottom; + marker.setAttribute("aria-hidden", "true"); + readerContext.chapterManifestObserver.unobserve(article); + readerContext.foliateScrollAnchors.unobserve(article); + for (const entry of readerContext.navigationState.tocEntries) + if (entry.chapterIndex === index) entry.target = null; + for (const image of article.querySelectorAll("img")) image.removeAttribute("src"); + article.replaceWith(marker); + article._chapterSearchHead = null; + }, + release: index => { + chapterBudget.release(retainedBytes.get(index)); + retainedBytes.delete(index); + }, + preserve: change => readerContext.foliateScrollAnchors.preserve(change) + }); + function trimChapterWindow() { + if (chapterWindowReady) chapterVirtualizer.trim(chapterFocusIndex); + } + function chapterPrefetchIndices() { + if (document.hidden) return []; + // A fixed opening prefix keeps extra front matter within the DOM retention target. + const forwardCount = Math.max(chapterPrefetchCount, chapterPrefetchMaxCount + 1 - chapterFocusIndex); + const nearby = manifest.chapters.slice(Math.max(0, chapterFocusIndex - 2), + chapterFocusIndex + forwardCount); + const indices = []; + let bytes = 0; + for (const chapter of nearby) { + bytes += chapter.bytes; + if (chapter.index > chapterFocusIndex + chapterPrefetchCount && bytes > chapterPrefetchBytes) break; + if (!loaded.has(chapter.index)) indices.push(chapter.index); + } + return indices; + } + function refreshChapterWindow() { + chapterWindowFrame = 0; + if (!chapterWindowReady || readerContext.readerAbortController.signal.aborted) return; + const view = readerContext.viewport.getBoundingClientRect(); + const rows = [...frame.querySelectorAll(":scope > [data-chapter]")]; + const current = rows.find(row => { + const rect = row.getBoundingClientRect(); + return rect.top <= view.top + 80 && rect.bottom > view.top + 80; + }) || rows.find(row => row.getBoundingClientRect().top > view.top); + if (current && !chapterNavigating) chapterFocusIndex = Number(current.dataset.chapter); + if (!chapterNavigating && current?.classList.contains("reader-chapter-sentinel") && + current.getBoundingClientRect().top < view.bottom) + fetchChapter(chapterAt(chapterFocusIndex)).catch(() => {}); + if (!chapterNavigating) chapterScheduler.prefetch(chapterPrefetchIndices()); + readerContext.foliateScrollAnchors.whenIdle(trimChapterWindow); + } + function scheduleChapterWindow() { + if (!chapterWindowFrame) chapterWindowFrame = requestAnimationFrame(refreshChapterWindow); + } + function handleChapterVisibility() { + if (document.hidden) chapterScheduler.prefetch([]); + else scheduleChapterWindow(); + } + readerContext.chapterManifestObserver = new IntersectionObserver( + scheduleChapterWindow, + { root: readerContext.viewport, rootMargin: "800px 0px" } + ); + chapterPreviousObserver = new IntersectionObserver((entries) => { + if (!chapterWindowReady || chapterNavigating || readerContext.readerAbortController.signal.aborted) return; + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const chapter = chapterAt(Number(entry.target.dataset.chapter)); + if (chapter) fetchChapter(chapter).catch(() => {}); + } + }, { root: readerContext.viewport }); + readerContext.viewport.addEventListener("scroll", scheduleChapterWindow, { passive: true }); + window.addEventListener("resize", scheduleChapterWindow); + document.addEventListener("visibilitychange", handleChapterVisibility); + let chapterCleaned = false; + readerContext.chapterManifestCleanup = () => { + if (chapterCleaned) return; + chapterCleaned = true; + chapterWindowReady = false; + cancelAnimationFrame(chapterWindowFrame); + readerContext.viewport.removeEventListener("scroll", scheduleChapterWindow); + window.removeEventListener("resize", scheduleChapterWindow); + document.removeEventListener("visibilitychange", handleChapterVisibility); + chapterScheduler.dispose(); + chapterVirtualizer.dispose(); + readerContext.chapterManifestObserver?.disconnect(); + chapterPreviousObserver?.disconnect(); + readerContext.chapterManifestObserver = null; + readerContext.chapterManifestLoader = null; + for (const bytes of retainedBytes.values()) chapterBudget.release(bytes); + retainedBytes.clear(); + loaded.clear(); + }; + readerContext.trackReaderResource(readerContext.chapterManifestCleanup); + readerContext.chapterManifestLoader = async (index) => { + const chapter = chapterAt(index); + if (!chapter) return null; + chapterFocusIndex = index; + chapterNavigating++; + try { await fetchChapter(chapter, true, chapterWindowReady); } + finally { chapterNavigating--; } + return frame.querySelector(`.reader-epub-chapter[data-chapter="${index}"]`); + }; + readerContext.chapterManifestLoader.count = manifest.chapters.length; + setChapterToc(); + await readerContext.getInitialReaderRestoration(); + const savedChapter = Number(readerContext.readerRestorationEntry?.chapterIndex) - 1; + const initialChapter = Number.isInteger(savedChapter) + ? Math.max(0, Math.min(manifest.chapters.length - 1, savedChapter)) + : 0; + chapterFocusIndex = manifest.chapters[initialChapter].index; + if (initialChapter > 0) ensureSentinel(manifest.chapters[initialChapter - 1], true); + try { + const firstChapter = fetchChapter(manifest.chapters[initialChapter]); + chapterScheduler.prefetch(chapterPrefetchIndices()); + await firstChapter; + } catch (error) { + if (readerContext.readerRestorationEntry?.chapterIndex == null) throw error; + throw Object.assign(new Error("阅读位置恢复失败,原进度已保留,请重试加载。", { cause: error }), { + code: "READER_RESTORE" + }); + } + chapterWindowReady = true; + if (readerContext.isReaderGenerationCurrent("navigation", openingGeneration)) readerContext.foliateScrollAnchors.invalidate(); + for (const article of frame.querySelectorAll(".reader-epub-chapter")) readerContext.foliateScrollAnchors.observe(article); + frame.style.overflowAnchor = "none"; + scheduleChapterWindow(); + readerContext.assertReaderActive(); + readerContext.markReaderContentReady(); + readerContext.status.textContent = `EPUB · ${manifest.chapters.length} 章`; + } + + function captureChapterPosition() { + if (readerContext.capability.mode !== "epub-chapters") return null; + const marker = readerContext.viewport.getBoundingClientRect().top + 8; + const chapters = [...readerContext.content.querySelectorAll(".reader-epub-chapter")]; + const chapter = + chapters.find((node) => node.getBoundingClientRect().bottom > marker) || + chapters[chapters.length - 1]; + return chapter + ? { + chapterIndex: Number(chapter.dataset.chapter), + chapterManifest: readerContext.chapterManifestUrl || readerContext.sourceUrl, + chapterOffset: marker - chapter.getBoundingClientRect().top + } + : null; + } + + async function restoreChapterPosition(entry, generation) { + if ( + /-epub-chapters-v5\//.test(readerContext.chapterManifestUrl || readerContext.sourceUrl) && + entry.chapterManifest !== (readerContext.chapterManifestUrl || readerContext.sourceUrl) + ) return false; + if (!readerContext.chapterManifestLoader || !Number.isInteger(entry.chapterIndex)) return false; + const chapter = await readerContext.chapterManifestLoader(entry.chapterIndex); + if (!chapter?.isConnected || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + readerContext.viewport.scrollTop += + chapter.getBoundingClientRect().top - + readerContext.viewport.getBoundingClientRect().top - + 8 + + (Number(entry.chapterOffset) || 0); + readerContext.foliateScrollAnchors.remember(); + readerContext.syncHeadingLocation(); + readerContext.updateProgressTools(); + readerContext.scheduleSave(); + return true; + } + + return { + renderChapterManifest, + captureChapterPosition, + restoreChapterPosition + }; +} diff --git a/static/reader-chapter-repository.js b/static/reader/formats/reader-chapter-repository.js similarity index 100% rename from static/reader-chapter-repository.js rename to static/reader/formats/reader-chapter-repository.js diff --git a/static/reader/formats/reader-docx-format.mjs b/static/reader/formats/reader-docx-format.mjs new file mode 100644 index 0000000000000000000000000000000000000000..d209c942cb85f0c7e28a4577e20bbe08da37353e --- /dev/null +++ b/static/reader/formats/reader-docx-format.mjs @@ -0,0 +1,129 @@ +export function createReaderDocxFormat(readerContext) { + async function renderDocx(prepared) { + const bytes = await readerContext.readPreparedBytes(prepared.response, prepared.engines); + readerContext.assertReaderActive(); + VoiceOfMLReaderSecurity.inspectZip(bytes, { + ...VoiceOfMLReaderSecurity.LIMITS, + archiveCompressedBytes: VoiceOfMLReaderSecurity.LIMITS.documentBytes + }); + const styles = document.createElement("div"); + styles.className = "docx-styles"; + const body = document.createElement("div"); + body.className = "docx-body"; + readerContext.content.append(styles, body); + const options = { + className: "reader-docx", + inWrapper: true, + breakPages: true, + ignoreLastRenderedPageBreak: false, + useBase64URL: false, + renderHeaders: true, + renderFooters: true, + renderFootnotes: true, + renderEndnotes: true, + renderChanges: false, + renderComments: false, + renderAltChunks: false, + debug: false + }; + const docxDocument = await readerContext.awaitReader(docx.parseAsync(bytes, options)); + readerContext.assertReaderActive(); + const urls = new Set(), urlsByPath = new Map(), contentTypes = docxDocument.contentTypes || []; + let released = false; + const release = () => { + released = true; + for (const url of urls) URL.revokeObjectURL(url); + urls.clear(); + urlsByPath.clear(); + }; + const untrack = readerContext.trackReaderResource(release); + readerContext.memoizeDocxAssets(docxDocument, () => released); + // Own this parsed document's images, fonts and numbering assets only. + docxDocument.blobToURL = (blob, path) => { + if (released || !blob) return null; + if (path && urlsByPath.has(path)) return urlsByPath.get(path); + const type = path && contentTypes.find(entry => entry.partName === path || + (entry.extension && path.endsWith(`.${entry.extension}`))); + const url = URL.createObjectURL(type ? new Blob([blob], {type:type.contentType}) : blob); + if (typeof url === "string" && url.startsWith("blob:")) { + urls.add(url); + if (path) urlsByPath.set(path, url); + } + return url; + }; + try { + const nodes = await readerContext.awaitReader(docx.renderDocument(docxDocument, options)); + readerContext.assertReaderActive(); + for (const node of nodes) (node.nodeName === "STYLE" ? styles : body).appendChild(node); + } catch (error) { + release(); + untrack(); + throw error; + } + readerContext.assertReaderActive(); + body.classList.toggle("reader-document-dark", readerContext.readerTheme === "dark"); + if (!(body.textContent || "").trim() && !body.querySelector("img, table, svg, canvas")) { + release(); + untrack(); + throw new Error("DOCX rendered no supported content"); + } + const pages = [...body.querySelectorAll(":scope > .reader-docx-wrapper > section.reader-docx")]; + readerContext.docxPageNodes = pages; + if (pages.length) { + readerContext.updateDocumentState({ pageCount: pages.length }); + readerContext.pageInput.max = String(readerContext.documentState.pageCount); + document.querySelector("#page-total").textContent = `/ ${readerContext.documentState.pageCount}`; + document.querySelector(".page-controls").hidden = false; + pages.forEach((page, index) => { + page.classList.add("reader-docx-page"); + page.dataset.page = String(index + 1); + page.style.setProperty("--reader-docx-height", page.style.minHeight || page.style.height || "1123px"); + }); + readerContext.setToc(readerContext.headingTocEntries(body)); + } + for (const link of body.querySelectorAll("a[href]")) { + const href = link.getAttribute("href") || ""; + if (!href.startsWith("#") && !/^https?:\/\//i.test(href)) link.removeAttribute("href"); + else if (!href.startsWith("#")) { + link.target = "_blank"; + link.rel = "noopener noreferrer"; + } + } + readerContext.markReaderContentReady(); + readerContext.status.textContent = readerContext.documentState.pageCount ? `${readerContext.documentState.pageCount} 页` : "DOCX"; + } + + function memoizeDocxResource(load, keyFor, isReleased) { + const pending = new Map(); + return (...args) => { + if (isReleased()) return Promise.resolve(null); + const key = keyFor(...args); + if (!key) return load(...args); + if (!pending.has(key)) { + const task = Promise.resolve().then(() => isReleased() ? null : load(...args)); + pending.set(key, task); + task.catch(() => { if (pending.get(key) === task) pending.delete(key); }); + } + return pending.get(key); + }; + } + + function memoizeDocxAssets(doc, isReleased) { + if (typeof doc.getPathById !== "function") return; + for (const [name, part] of [["loadDocumentImage", "documentPart"], + ["loadNumberingImage", "numberingPart"], ["loadFont", "fontTablePart"]]) { + if (typeof doc[name] !== "function") continue; + const original = doc[name].bind(doc); + doc[name] = readerContext.memoizeDocxResource(original, (id, parameter) => { + const path = doc.getPathById(name === "loadDocumentImage" ? parameter || doc[part] : doc[part], id); + return path ? (name === "loadFont" ? `${path}\u0000${parameter}` : path) : null; + }, isReleased); + } + } + + return { + renderDocx, + memoizeDocxResource, + memoizeDocxAssets + }; +} diff --git a/static/reader/formats/reader-foliate-format.mjs b/static/reader/formats/reader-foliate-format.mjs new file mode 100644 index 0000000000000000000000000000000000000000..af24405c73ad4b65ca7446af8cdb41c29345c09d --- /dev/null +++ b/static/reader/formats/reader-foliate-format.mjs @@ -0,0 +1,637 @@ +export function createReaderFoliateFormat(readerContext) { + function foliateSectionRoot(article) { + return article?.shadowRoot || article; + } + + function foliateSectionQuery(article, selector) { + return readerContext.foliateSectionRoot(article)?.querySelector(selector) || null; + } + + function foliateSectionCandidates() { + return [ + ...readerContext.content.querySelectorAll( + ".foliate-continuous > article[data-section]:not(.foliate-section-placeholder), .reader-epub-chapter" + ) + ].flatMap((article) => [ + article, + ...readerContext.foliateSectionRoot(article).querySelectorAll( + ":is(h1,h2,h3,h4,h5,h6,p,li,blockquote,pre,img,table)" + ) + ]); + } + + function syncFoliateScrollLocation() { + if (!readerContext.foliateContinuous || !readerContext.navigationState.tocEntries.length) return; + const rows = [...document.querySelectorAll(".foliate-continuous article[data-section]")], + top = readerContext.viewport.getBoundingClientRect().top + 80; + let sectionIndex = -1, + sectionNode = null; + for (const row of rows) + if (row.getBoundingClientRect().top <= top) { + sectionIndex = Number(row.dataset.section); + sectionNode = row; + } + if (!sectionNode) return; + let best = -1; + let bestPosition = -Infinity; + for (const [index, entry] of readerContext.navigationState.tocEntries.entries()) { + if (entry.sectionIndex !== sectionIndex) continue; + let position = sectionNode.getBoundingClientRect().top; + if (entry.anchor) { + const root = readerContext.foliateSectionRoot(sectionNode); + const anchor = entry.anchor({ + getElementById: (id) => root.querySelector(`[id="${CSS.escape(id)}"]`), + querySelector: (selector) => root.querySelector(selector) + }); + if (anchor) position = anchor.getBoundingClientRect().top; + } + if (entry.fragment) { + const anchor = readerContext.foliateSectionQuery( + sectionNode, + `[id="${CSS.escape(entry.fragment)}"], [name="${CSS.escape(entry.fragment)}"]` + ); + if (anchor) position = anchor.getBoundingClientRect().top; + } + if (position <= top && (position > bestPosition || (position === bestPosition && index > best))) { + best = index; + bestPosition = position; + } + } + if (best < 0) { + let firstPosition = Infinity; + for (const [index, entry] of readerContext.navigationState.tocEntries.entries()) { + if (entry.sectionIndex !== sectionIndex) continue; + const position = entry.target?.getBoundingClientRect?.().top; + if (Number.isFinite(position) && position < firstPosition) { + best = index; + firstPosition = position; + } + } + } + if (best >= 0 && best !== readerContext.navigationState.currentChapterIndex) { + readerContext.updateNavigationState({ currentChapterIndex: best }); + readerContext.updateTocCurrentMark(); + readerContext.updateProgressTools(); + } + } + + function refreshFoliateWindow() { + readerContext.foliateWindowFrame = 0; + readerContext.foliateSectionLoader?.refresh?.(); + } + + function scheduleFoliateWindowRefresh() { + if (!readerContext.foliateWindowFrame) readerContext.foliateWindowFrame = requestAnimationFrame(readerContext.refreshFoliateWindow); + } + + function trimFoliateSections() { + if (!readerContext.foliateContinuous || !readerContext.foliateSectionVirtualizer) return; + const marker = readerContext.viewport.getBoundingClientRect().top + 80, + rows = [...document.querySelectorAll(".foliate-continuous article[data-section]")], + current = + rows.find((row) => { + const rect = row.getBoundingClientRect(); + return rect.top <= marker && rect.bottom > marker; + }) || rows.find((row) => row.getBoundingClientRect().top > marker); + if (current) readerContext.foliateSectionVirtualizer.trim(Number(current.dataset.section)); + } + + function scheduleFoliateScrollSync() { + if (readerContext.foliateScrollFrame) return; + readerContext.foliateScrollFrame = requestAnimationFrame(() => { + readerContext.foliateScrollFrame = 0; + readerContext.syncFoliateScrollLocation(); + readerContext.foliateScrollAnchors.whenIdle(readerContext.trimFoliateSections); + }); + } + + async function activateFoliateTocEntry(entry, generation = readerContext.beginReaderNavigation()) { + const book = readerContext.epubRendition?.book, + href = String(entry.href || ""), + target = await readerContext.epubRendition?.book?.resolveHref(href); + if (!book || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + const section = target && book.sections[target.index], + sections = book.sections.filter((item) => item.linear !== "no"), + visibleIndex = section ? sections.indexOf(section) : -1; + if (!section || visibleIndex < 0 || !readerContext.foliateSectionLoader) return false; + let node = await readerContext.foliateSectionLoader(visibleIndex); + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + // Background neighbors are not part of a demanded TOC destination. + await readerContext.waitForReader(0, true); + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + if (!node?.isConnected) node = await readerContext.foliateSectionLoader(visibleIndex); + if (!node?.isConnected || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + const root = readerContext.foliateSectionRoot(node), + anchorDocument = { + getElementById: (id) => root.querySelector(`[id="${CSS.escape(id)}"]`), + querySelector: (selector) => root.querySelector(selector) + }; + let anchor = null; + try { + anchor = typeof target.anchor === "function" ? target.anchor(anchorDocument) : null; + } catch (_) {} + if (!anchor) { + const fragment = href.split("#")[1] || "", + id = fragment ? decodeURIComponent(fragment) : ""; + anchor = id ? root.querySelector(`[id="${CSS.escape(id)}"], [name="${CSS.escape(id)}"]`) : null; + } + const destination = anchor || node; + readerContext.foliateScrollAnchors.invalidate(); + readerContext.viewport.scrollTop = Math.max( + 0, + readerContext.viewport.scrollTop + + destination.getBoundingClientRect().top - + readerContext.viewport.getBoundingClientRect().top - + 8 + ); + readerContext.syncFoliateScrollLocation(); + readerContext.updateTocCurrentMark(); + readerContext.updateProgressTools(); + readerContext.scheduleSave(); + readerContext.setReaderPanelOpen(false, true); + readerContext.foliateScrollAnchors.remember(); + return true; + } + + function cloneFoliateDocument(source) { + const doc = source.cloneNode(true), + original = source.createTreeWalker(source, NodeFilter.SHOW_TEXT), + cloned = doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT); + const sourceNodes = new WeakMap(); + while (original.nextNode() && cloned.nextNode()) + sourceNodes.set(cloned.currentNode, original.currentNode); + return { doc: readerContext.sanitizeEpubDocument(doc), sourceNodes }; + } + + function foliateSearchAnchor(source, sourceNodes, body, renderedBody) { + const sourceWalker = body.ownerDocument.createTreeWalker(body, NodeFilter.SHOW_TEXT); + const pairs = []; + let offset = 0; + while (sourceWalker.nextNode()) { + const node = sourceWalker.currentNode; + const original = sourceNodes.get(node); + if (original) pairs.push({ original, offset }); + offset += node.length; + } + return (anchor) => { + const range = anchor(source); + if (!range || typeof range.intersectsNode !== "function") return null; + const intervals = []; + for (const { original, offset } of pairs) { + if (!range.intersectsNode(original)) continue; + const start = range.startContainer === original ? range.startOffset : 0; + const end = range.endContainer === original ? range.endOffset : original.length; + if (end > start) intervals.push({ start: offset + start, end: offset + end }); + } + const walker = document.createTreeWalker(renderedBody, NodeFilter.SHOW_TEXT), + selected = []; + let offset = 0; + while (walker.nextNode()) { + const node = walker.currentNode, + end = offset + node.length; + for (const interval of intervals) + if (interval.start < end && interval.end > offset) + selected.push({ + node, + start: Math.max(0, interval.start - offset), + end: Math.min(node.length, interval.end - offset) + }); + offset = end; + } + return selected; + }; + } + + async function settleReaderImages(root, timeoutMs = 2500) { + readerContext.assertReaderActive(); + const images = [...root.querySelectorAll("img[src],img[srcset]")]; + if (!images.length) return; + let timeout = 0, active = true; + const cleanups = new Set(); + const decoded = Promise.allSettled( + images.map(async (image) => { + // Sections are prepared off-DOM; lazy images cannot enter a viewport yet. + image.loading = "eager"; + image.decoding = "async"; + if (!image.complete) await new Promise((resolve) => { + const finish = () => { + image.removeEventListener("load", finish); + image.removeEventListener("error", finish); + cleanups.delete(finish); + resolve(); + }; + cleanups.add(finish); + image.addEventListener("load", finish, { once: true }); + image.addEventListener("error", finish, { once: true }); + if (image.complete) finish(); + }); + if (active) { + try { await image.decode?.(); } catch (_) {} + } + }) + ); + try { + await readerContext.awaitReader( + Promise.race([ + decoded, + new Promise((resolve) => { + timeout = setTimeout(resolve, timeoutMs); + }) + ]) + ); + } finally { + active = false; + clearTimeout(timeout); + for (const cleanup of cleanups) cleanup(); + } + } + + function rewriteFoliateResources(doc, section) { + const safeEmbeddedValue = (value) => + /^(?:blob:|data:image\/(?:gif|png|jpe?g|webp);)/i.test(String(value || "").trim()); + const archiveResourceUrl = (value) => { + const raw = String(value || "").trim(); + if (!raw || raw.startsWith("#") || raw.includes("#") || safeEmbeddedValue(raw)) + return safeEmbeddedValue(raw) ? raw : null; + if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(raw)) return null; + try { + const resolved = String( + (section.resolveResourceHref + ? section.resolveResourceHref(raw) + : section.resolveHref?.(raw)) || "" + ); + if ( + !resolved || + /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(resolved) || + /[\\\x00]/.test(resolved) || + resolved.split("/").some((part) => !part || part === "." || part === "..") + ) + return null; + return `/api/reader-resource?book=${encodeURIComponent(readerContext.sourceUrl)}&path=${encodeURIComponent(resolved)}`; + } catch (_) { + return null; + } + }; + for (const element of doc.querySelectorAll( + "img[src],source[src],audio[src],video[src],video[poster]" + )) { + for (const name of ["src", "poster"]) + if (element.hasAttribute(name)) { + const safe = archiveResourceUrl(element.getAttribute(name)); + if (safe) element.setAttribute(name, safe); + else element.removeAttribute(name); + } + } + for (const element of doc.querySelectorAll("[srcset]")) { + const value = element.getAttribute("srcset") || ""; + const candidates = value + .split(",") + .map((part) => { + const [raw, ...descriptor] = part.trim().split(/\s+/); + const safe = archiveResourceUrl(raw); + return safe ? [safe, ...descriptor].join(" ") : null; + }) + .filter(Boolean); + if (candidates.length) element.setAttribute("srcset", candidates.join(", ")); + else element.removeAttribute("srcset"); + } + for (const image of doc.querySelectorAll("image")) { + const value = + image.getAttributeNS("http://www.w3.org/1999/xlink", "href") || + image.getAttribute("xlink:href") || + image.getAttribute("href"); + const safe = archiveResourceUrl(value); + if (safe) { + image.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", safe); + image.setAttribute("href", safe); + } else { + image.removeAttributeNS("http://www.w3.org/1999/xlink", "href"); + image.removeAttribute("xlink:href"); + image.removeAttribute("href"); + } + } + for (const link of doc.querySelectorAll("a[href]")) { + const href = link.getAttribute("href") || ""; + if (/\.(?:gif|png|jpe?g|webp)$/i.test(href)) { + const resource = archiveResourceUrl(href); + if (resource) { + link.setAttribute("href", resource); + link.target = "_blank"; + link.rel = "noopener noreferrer"; + } + } + if (/^(?:https?:|mailto:|tel:)/i.test(href)) { + link.target = "_blank"; + link.rel = "noopener noreferrer"; + } + } + } + + async function createFoliateSection(section, index) { + const signal = arguments[2] || readerContext.readerAbortController.signal; + const priority = arguments[3] || "high"; + const source = await readerContext.awaitReader(section.createDocument(), signal); + const { doc, sourceNodes } = readerContext.cloneFoliateDocument(source); + readerContext.assertReaderActive(); + readerContext.rewriteFoliateResources(doc, section); + const styles = [...doc.querySelectorAll("style,link")].map((node) => node.cloneNode(true)); + for (const node of doc.querySelectorAll("style,link")) node.remove(); + const article = document.createElement("article"); + article.dataset.section = String(index); + const shadow = article.attachShadow({ mode: "open" }), + baseStyle = document.createElement("style"), + sectionBody = document.createElement("div"); + // Chapter styles live in a shadow root, so document-level preferences cannot reach them. + baseStyle.textContent = ` + :host { display: block; color: inherit; } + * { box-sizing: border-box; } + .reader-section-body { display: block; color: inherit; line-height: inherit; } + .reader-section-body > :first-child { margin-top: 0 !important; } + :host(.reader-document-dark) .reader-section-body, + :host(.reader-document-dark) .reader-section-body :where(:not(svg, svg *, img, canvas, video, audio, math, math *)) { + color: inherit !important; + background-color: transparent !important; + border-color: #4a5056 !important; + text-shadow: none !important; + } + a { color: var(--reader-book-link) !important; } + :host(.reader-document-dark) .reader-section-body a { color: var(--reader-book-link) !important; } + img, svg, video { max-width: 100%; height: auto; } + mark.full-search-highlight { background: #ffd54f; color: #111; } + :host(.reader-document-dark) .reader-section-body mark.full-search-highlight { background: #ffd54f !important; color: #111 !important; } + @media (prefers-contrast: more) { + .reader-section-body a[href] { text-decoration: underline !important; } + .reader-section-body mark.full-search-highlight { outline: 2px solid currentColor; } + } + `; + sectionBody.className = "reader-section-body"; + const body = readerContext.epubContentBody(doc); + // Import the sanitized nodes directly. Serializing and reparsing legacy + // nested anchors lets HTML's adoption rules reorder footnotes and citations. + if (body) + sectionBody.append(...[...body.childNodes].map((node) => document.importNode(node, true))); + article._resolveSearchAnchor = readerContext.foliateSearchAnchor(source, sourceNodes, body, sectionBody); + shadow.append(baseStyle, ...styles, sectionBody); + for (const image of sectionBody.querySelectorAll("img")) { + image.loading = priority === "high" ? "eager" : "lazy"; + image.fetchPriority = priority; + image.decoding = "async"; + } + signal.throwIfAborted(); + readerContext.assertReaderActive(); + article.classList.toggle("reader-document-dark", readerContext.readerTheme === "dark"); + return article; + } + + function setupFoliateWindow(stream, sections) { + let focus = readerContext.initialReaderTargetSection ?? 0, targetPending = false; + stream.style.overflowAnchor = "none"; + const existing = index => stream.querySelector(`article[data-section="${index}"]:not(.foliate-section-placeholder)`); + const ensurePlaceholder = index => { + if (!sections[index] || stream.querySelector(`article[data-section="${index}"]`)) return; + const placeholder = document.createElement("article"); + placeholder.className = "foliate-section-placeholder"; + placeholder.dataset.section = String(index); + placeholder.style.setProperty("--foliate-placeholder-height", `${Math.max(400, readerContext.viewport.clientHeight)}px`); + placeholder.setAttribute("aria-label", `加载第 ${index + 1} 节`); + const next = [...stream.children].find(node => Number(node.dataset.section) > index); + readerContext.foliateScrollAnchors.preserve(() => stream.insertBefore(placeholder, next || null)); + observer.observe(placeholder); + }; + const warm = () => { + const indices = []; + for (const offset of [-1, 1, -2, 2, 3, 4, 5, 6]) { + const index = focus + offset; + if (sections[index] && !existing(index)) indices.push(index); + } + scheduler.prefetch(document.hidden ? [] : indices); + }; + const refresh = () => { + if (readerContext.readerAbortController.signal.aborted) return; + const marker = readerContext.viewport.getBoundingClientRect().top + 80; + const rows = [...stream.children]; + const current = rows.find(node => { + const rect = node.getBoundingClientRect(); + return rect.top <= marker && rect.bottom > marker; + }) || rows.find(node => node.getBoundingClientRect().top > marker); + const index = current ? Number(current.dataset.section) : focus; + if (targetPending && index !== focus && !readerContext.foliateScrollAnchors.scrolling) return; + targetPending = false; + focus = index; + if (current?.classList.contains("foliate-section-placeholder")) scheduler.load(index).catch(() => {}); + warm(); + readerContext.foliateScrollAnchors.whenIdle(readerContext.trimFoliateSections); + }; + const observer = new IntersectionObserver(readerContext.scheduleFoliateWindowRefresh, + { root: readerContext.viewport, rootMargin: `${readerContext.FOLIATE_PREFETCH_MARGIN}px` }); + readerContext.foliateSectionObserver = observer; + const commitSection = (index, article) => + readerContext.foliateScrollAnchors.preserve(() => { + const placeholder = stream.querySelector( + `article.foliate-section-placeholder[data-section="${index}"]` + ), + next = [...stream.querySelectorAll("article[data-section]")].find( + (item) => Number(item.dataset.section) > index + ); + if (placeholder) { + observer.unobserve(placeholder); + placeholder.replaceWith(article); + } else stream.insertBefore(article, next || null); + observer.observe(article); + readerContext.foliateScrollAnchors.observe(article); + ensurePlaceholder(index - 1); + ensurePlaceholder(index + 1); + return article; + }); + const scheduler = readerContext.readerRuntime.track(VoiceOfMLReaderChapters.createChapterScheduler({ + create: async (index, signal, priority) => { + const article = await readerContext.createFoliateSection(sections[index], index, signal, priority); + signal.throwIfAborted(); + if (existing(index)) return existing(index); + return commitSection(index, article); + } + })); + readerContext.foliateChapterRepository = VoiceOfMLReaderChapters.createChapterRepository({ + count: sections.length, find: existing, create: index => scheduler.load(index) + }); + readerContext.foliateSectionVirtualizer = VoiceOfMLReaderVirtual.createSectionVirtualizer({ + limit: readerContext.FOLIATE_LOADED_SECTION_LIMIT, + getLoaded: () => [ + ...stream.querySelectorAll("article[data-section]:not(.foliate-section-placeholder)") + ], + getIndex: (article) => Number(article.dataset.section), + getHeight: (article) => article.getBoundingClientRect().height, + canVirtualize: (article) => { + const rect = article.getBoundingClientRect(), + viewportRect = readerContext.viewport.getBoundingClientRect(); + return ( + rect.bottom < viewportRect.top - readerContext.FOLIATE_PREFETCH_MARGIN || + rect.top > viewportRect.bottom + readerContext.FOLIATE_PREFETCH_MARGIN + ) && !article.contains(document.activeElement) && !article.shadowRoot?.activeElement && + !readerContext.foliateSectionRoot(article).querySelector("mark.full-search-highlight") && + ![window.getSelection(), article.shadowRoot?.getSelection?.()].some(selection => + selection && !selection.isCollapsed && (selection.containsNode(article, true) || + selection.anchorNode?.getRootNode() === article.shadowRoot)); + }, + virtualize: (article, index, height) => { + const placeholder = document.createElement("article"); + placeholder.className = "foliate-section-placeholder"; + placeholder.dataset.section = String(index); + placeholder.style.setProperty("--foliate-placeholder-height", `${height}px`); + placeholder.setAttribute("aria-hidden", "true"); + observer.unobserve(article); + readerContext.foliateScrollAnchors.unobserve(article); + for (const image of readerContext.foliateSectionRoot(article).querySelectorAll("img")) { + image.removeAttribute("src"); + image.removeAttribute("srcset"); + } + article._resolveSearchAnchor = null; + article.replaceWith(placeholder); + observer.observe(placeholder); + return placeholder; + }, + release: (index) => readerContext.foliateChapterRepository.release(index), + preserve: (change) => readerContext.foliateScrollAnchors.preserve(change) + }); + readerContext.foliateSectionLoader = index => { + focus = index; + targetPending = true; + const task = existing(index) ? Promise.resolve(existing(index)) : scheduler.load(index, true, true); + warm(); + return task; + }; + readerContext.foliateSectionLoader.refresh = refresh; + readerContext.foliateSectionSettler = () => readerContext.waitForReader(0, true); + const scroll = readerContext.scheduleFoliateWindowRefresh; + readerContext.viewport.addEventListener("scroll", scroll, { passive: true }); + document.addEventListener("visibilitychange", scroll); + readerContext.trackReaderResource(() => { + readerContext.viewport.removeEventListener("scroll", scroll); + document.removeEventListener("visibilitychange", scroll); + }); + } + + async function foliateTocEntries(view, sections) { + readerContext.assertReaderActive(); + const entries = []; + const append = (items, depth = 0) => { + for (const item of items || []) { + const href = item?.href ? String(item.href) : ""; + if (href || item?.label) + entries.push({ + label: item.label || "未命名章节", + href, + depth, + group: !href, + activate: () => view.goTo(href) + }); + append(item.subitems, depth + 1); + } + }; + append(view.book.toc); + const sectionIndices = new Map(); + for (const [index, section] of sections.entries()) + if (!sectionIndices.has(section)) sectionIndices.set(section, index); + let next = 0, yieldDeadline = performance.now() + 8, yieldTask = null; + const resolveEntries = async () => { + while (next < entries.length) { + readerContext.assertReaderActive(); + if (yieldTask || performance.now() >= yieldDeadline) { + yieldTask ||= readerContext.waitForReader().finally(() => { + yieldTask = null; + yieldDeadline = performance.now() + 8; + }); + await yieldTask; + continue; + } + const entry = entries[next++]; + if (entry.group) { + entry.sectionIndex = -1; + entry.fragment = ""; + continue; + } + try { + const target = await view.book.resolveHref(entry.href), + section = target && view.book.sections[target.index]; + readerContext.assertReaderActive(); + entry.sectionIndex = section ? sectionIndices.get(section) ?? -1 : -1; + entry.anchor = typeof target?.anchor === "function" ? target.anchor : null; + const fragment = entry.href.split("#")[1] || ""; + entry.fragment = fragment ? decodeURIComponent(fragment) : ""; + } catch (_) { + readerContext.assertReaderActive(); + entry.sectionIndex = -1; + entry.fragment = ""; + } + } + }; + await readerContext.awaitReader(Promise.all(Array.from({ length: Math.min(8, entries.length) }, resolveEntries))); + readerContext.assertReaderActive(); + return entries; + } + + async function renderFoliate() { + const prepared = readerContext.prepareReaderDocument([import("/static/foliate-reader/view.js")]); + const [response, bytes] = await Promise.all([prepared.response,readerContext.readPreparedBytes( + prepared.response,prepared.engines,VoiceOfMLReaderSecurity.LIMITS.archiveCompressedBytes)]); + readerContext.assertReaderActive(); + if (VoiceOfMLReaderSecurity.isZipContainer(readerContext.extension, bytes)) + VoiceOfMLReaderSecurity.inspectZip(bytes); + const view = document.createElement("foliate-view"), + stream = document.createElement("div"); + view.className = "foliate-reader-view"; + stream.className = "foliate-continuous"; + readerContext.content.replaceChildren(readerContext.loadingIndicator, view, stream); + readerContext.epubRendition = view; + await view.open( + new File([bytes], new URL(readerContext.sourceUrl).pathname.split("/").pop() || "book.epub", { + type: response.headers.get("content-type") || "application/epub+zip" + }) + ); + readerContext.assertReaderActive(); + readerContext.epubBook = view.book; + view.style.display = "none"; + const sections = view.book.sections.filter((section) => section.linear !== "no"); + await readerContext.getInitialReaderRestoration(); + const savedSection = Number(readerContext.readerRestorationEntry?.foliateSection); + const initialSection = Number.isInteger(savedSection) + ? Math.max(0, Math.min(sections.length - 1, savedSection)) + : 0; + readerContext.initialReaderTargetSection = readerContext.readerRestorationEntry?.foliateSection == null ? null : initialSection; + readerContext.setupFoliateWindow(stream, sections); + if (sections[initialSection]) await readerContext.foliateChapterRepository.load(initialSection); + if (initialSection > 0) await readerContext.foliateChapterRepository.load(initialSection - 1); + readerContext.assertReaderActive(); + // Resolving every TOC href can be expensive for large books. The first + // chapter is already visible, so finish the opening path before enriching + // the navigation panel in the background. + readerContext.foliateTocEntries(view, sections) + .then((entries) => { + if (entries.length && !readerContext.readerAbortController.signal.aborted) readerContext.setToc(entries); + }) + .catch(() => {}); + readerContext.scheduleFoliateScrollSync(); + readerContext.markReaderContentReady(); + readerContext.status.textContent = "EPUB"; + } + + return { + foliateSectionRoot, + foliateSectionQuery, + foliateSectionCandidates, + syncFoliateScrollLocation, + refreshFoliateWindow, + scheduleFoliateWindowRefresh, + trimFoliateSections, + scheduleFoliateScrollSync, + activateFoliateTocEntry, + cloneFoliateDocument, + foliateSearchAnchor, + settleReaderImages, + rewriteFoliateResources, + createFoliateSection, + setupFoliateWindow, + foliateTocEntries, + renderFoliate + }; +} diff --git a/static/reader-format-adapters.js b/static/reader/formats/reader-format-adapters.js similarity index 100% rename from static/reader-format-adapters.js rename to static/reader/formats/reader-format-adapters.js diff --git a/static/reader/formats/reader-format-controller.mjs b/static/reader/formats/reader-format-controller.mjs new file mode 100644 index 0000000000000000000000000000000000000000..a8a908158e419c00292bfcdbf4ff7fa7e46ec540 --- /dev/null +++ b/static/reader/formats/reader-format-controller.mjs @@ -0,0 +1,280 @@ +export function createReaderFormatController(readerContext) { + function disposeFormatResources(mode) { + if (mode === "epub-chapters") { + readerContext.chapterManifestCleanup?.(); + readerContext.chapterManifestCleanup = null; + } + if (mode === "swf") { + readerContext.swfPlayer?.remove(); + readerContext.swfPlayer = null; + } + if (["pdf", "pdf-pages", "image-pages"].includes(mode)) { + readerContext.pdfDocument = null; + readerContext.pdfHybridUrl = ""; + readerContext.pdfHybridDocumentPromise = null; + readerContext.pdfPageManifest = null; + readerContext.pdfOcrManifest = null; + readerContext.pdfOcrManifestPromise = null; + readerContext.pdfV3Repository?.dispose(); readerContext.pdfTextLayerRepository?.dispose(); + for (const shell of readerContext.pdfManifestShells) shell?._v3PreviewRelease?.(); + readerContext.pdfV3Repository = readerContext.pdfTextLayerRepository = null; + readerContext.pdfV3Manifest = readerContext.pdfV3Map = null; + readerContext.pdfTextContentCache.clear(); + readerContext.pdfTextContentPromises.clear(); + readerContext.pdfTextContentCacheBytes = 0; + readerContext.pdfOcrPageCache.clear(); + readerContext.pdfOcrPageCacheBytes = 0; + readerContext.clearPdfManifestPrefetches(); + readerContext.pdfBookSearchClient?.dispose(); + readerContext.pdfBookSearchClient = null; + readerContext.pdfShellWindow?.dispose(); + readerContext.pdfFirstPagePreload?.removeAttribute("src"); + readerContext.pdfFirstPagePreload = null; + for (const canvas of readerContext.content.querySelectorAll(".reader-page canvas")) { + canvas.width = 0; + canvas.height = 0; + } + for (const image of readerContext.content.querySelectorAll(".reader-page img")) image.removeAttribute("src"); + } + if (mode === "foliate") { + readerContext.foliateSectionObserver?.disconnect(); + readerContext.foliateSectionObserver = null; + readerContext.foliateSectionVirtualizer?.dispose(); + readerContext.foliateChapterRepository?.dispose(); + readerContext.foliateSectionVirtualizer = null; + readerContext.foliateChapterRepository = null; + readerContext.foliateSectionLoader = null; + readerContext.foliateSectionSettler = null; + try { + const closing = readerContext.epubRendition?.close?.(); + closing?.catch?.(() => {}); + } catch (_) {} + readerContext.epubRendition = null; + readerContext.epubBook = null; + } + if (mode === "html") { + readerContext.htmlFrame?.remove(); + readerContext.htmlFrame = null; + readerContext.content.classList.remove("reader-content--spreadsheet"); + } + if (["audio", "video"].includes(mode) && readerContext.mediaElement) { + readerContext.mediaElement.pause(); + readerContext.mediaElement.removeAttribute("src"); + readerContext.mediaElement.load(); + readerContext.mediaElement = null; + } + } + + function registerReaderFormatAdapters() { + const formats = { + "pdf-pages": [ + () => { + return readerContext.fetchReaderResponse(); + }, + readerContext.renderPdfPages + ], + "image-pages": [ + () => readerContext.fetchReaderResponse(), + readerContext.renderImagePages + ], + pdf: [readerContext.loadPdfDocument, readerContext.renderPdf], + image: [readerContext.loadImageDocument, readerContext.renderImageDocument], + text: [readerContext.loadTextDocument, readerContext.renderTextDocument], + markdown: [readerContext.loadMarkdownDocument, readerContext.renderMarkdownDocument], + html: [readerContext.loadHtmlDocument, readerContext.renderHtml], + "epub-chapters": [readerContext.loadChapterManifestDocument, readerContext.renderChapterManifest], + docx: [readerContext.loadDocxDocument, readerContext.renderDocx], + audio: [readerContext.loadMediaDocument, () => readerContext.renderMedia("audio")], + video: [readerContext.loadMediaDocument, () => readerContext.renderMedia("video")], + swf: [readerContext.loadSwfDocument, readerContext.renderSwf], + foliate: [readerContext.loadMediaDocument, readerContext.renderFoliate] + }; + for (const [mode, [open, render]] of Object.entries(formats)) + readerContext.formatAdapters.register(mode, { + open, + render, + restore: (entry, generation) => readerContext.restoreFormat(mode, entry, generation), + dispose: () => readerContext.disposeFormatResources(mode) + }); + } + + async function resolveReaderSource() { + if (!readerContext.readerId || readerContext.sourceUrl) return; + readerContext.setReaderStage("resolve"); + let resolved = readerContext.cachedReaderData; + if (!resolved?.url) { + try { + resolved = await readerContext.resolveReaderId(readerContext.readerId); + } catch (error) { + readerContext.assertReaderActive(); + if (!readerContext.localReaderData?.url) throw error; + resolved = readerContext.localReaderData; + } + } + readerContext.assertReaderActive(); + readerContext.sourceUrl = readerContext.normalizeSourceUrl(resolved.url || ""); + readerContext.contentUrl = readerContext.readerContentUrl(readerContext.sourceUrl); + readerContext.downloadUrl = resolved.download || readerContext.sourceUrl; + readerContext.chapterManifestUrl = resolved.chapter_manifest || ""; + readerContext.fallbackUrl = resolved.fallback || ""; + if (resolved.extension) readerContext.extension = String(resolved.extension).toLowerCase(); + if ( + readerContext.extension === "pdf" && + readerContext.sourceUrl.includes("/api/reader-bucket-resource?") && + new URL(readerContext.sourceUrl).searchParams.get("path")?.endsWith("page-manifest.json") + ) + readerContext.extension = "pdf-pages"; + readerContext.capability = readerContext.readerRuntime.negotiate(VoiceOfMLReader.capability(readerContext.extension)); + if (readerContext.capability.mode !== "pdf" && !readerContext.pdfPrimarySource()) readerContext.releasePdfWorker?.(); + readerContext.readerRuntime.update("source", { + url: readerContext.sourceUrl, + contentUrl: readerContext.contentUrl, + downloadUrl: readerContext.downloadUrl, + extension: readerContext.extension, + metadata: resolved + }); + readerContext.applyReaderMetadata(resolved); + } + + async function start() { + const generation = readerContext.readerRuntime.currentGeneration("navigation"); + // A PDF link already tells us which engine is needed. Start its import while + // an ID-only Reader link is resolving its source URL. The promise is reused + // by loadPdfDocument, so this does not create a second module request. + if (readerContext.extension === "pdf" || /\.pdf(?:$|[?#])/i.test(readerContext.sourceUrl)) readerContext.preloadPdfEngine().catch(() => {}); + readerContext.readerSourcePromise = readerContext.measurePdf(readerContext.resolveReaderSource(), "resolve"); + await readerContext.readerSourcePromise; + readerContext.assertReaderActive(); + if (readerContext.readerId && /\/calibre-chm-epub-[^/]+\/document\.epub(?:$|\?)/i.test(readerContext.sourceUrl)) { + readerContext.extension = "epub"; + readerContext.readerRuntime.update("source", { extension: readerContext.extension }); + readerContext.capability = readerContext.readerRuntime.negotiate(VoiceOfMLReader.capability(readerContext.extension)); + readerContext.content.dataset.mode = readerContext.capability.mode; + document.querySelector(".page-controls").hidden = true; + } + readerContext.syncCapabilityControls(); + readerContext.downloadButton.href = readerContext.readerDownloadUrl(readerContext.documentState.title, readerContext.downloadUrl); + if ( + (!readerContext.validSource(readerContext.sourceUrl) && !readerContext.validSource(readerContext.chapterManifestUrl) && !readerContext.readerId) || + readerContext.capability.readerMode === VoiceOfMLReader.ReaderMode.UNSUPPORTED + ) + return readerContext.fail("此文件暂不支持在线阅读,请下载原文件。", "READER_UNSUPPORTED"); + readerContext.formatAdapters.activate(readerContext.capability.mode); + readerContext.applyReaderMetadata(readerContext.resolvedReaderData); + if (readerContext.validOcr(readerContext.ocrUrl)) { + const ocr = document.querySelector("#ocr") || document.createElement("a"); + ocr.id = "ocr"; + ocr.className = "text-button"; + ocr.target = "_blank"; + ocr.rel = "noopener noreferrer"; + ocr.textContent = "OCR"; + if (!ocr.parentElement) document.querySelector(".reader-actions")?.prepend(ocr); + ocr.href = readerContext.ocrUrl; + ocr.hidden = false; + } + try { + if (readerContext.capability.mode === "foliate") { + readerContext.setReaderStage("foliate"); + readerContext.foliateContinuous = true; + const restorationPromise = readerContext.loadReaderRestoration(); + readerContext.readerRestorationPromise = restorationPromise; + await readerContext.awaitReader(readerContext.renderFoliate()); + readerContext.assertReaderActive(); + const restored = await readerContext.awaitReader(restorationPromise); + await readerContext.applyInitialRestoration(restored, generation); + readerContext.initialReaderTargetSection = null; + readerContext.assertReaderActive(); + if (!readerContext.setReaderPhase("ready")) return; + readerContext.updateDocumentState({ restorationReady: !readerContext.restorationFailed }); + readerContext.scheduleSave(); + return; + } + readerContext.setReaderStage("prepare"); + const restorationPromise = readerContext.loadReaderRestoration(); + readerContext.readerRestorationPromise = restorationPromise; + const prepareResources = new Set(readerContext.readerResources); + try { + const prepared = await readerContext.awaitReader(readerContext.formatAdapters.active.open()); + readerContext.assertReaderActive(); + await readerContext.formatAdapters.active.render(prepared); + } catch (error) { + readerContext.assertReaderActive(); + const originalExtension = String(readerContext.resolvedReaderData?.original_extension || "").toLowerCase(); + if (readerContext.capability.mode !== "image-pages" || !readerContext.validSource(readerContext.downloadUrl) || + !["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(originalExtension)) throw error; + for (const cleanup of [...readerContext.readerResources]) { + if (prepareResources.has(cleanup)) continue; + cleanup(); + readerContext.readerResources.delete(cleanup); + } + readerContext.formatAdapters.dispose(); + readerContext.content.replaceChildren(); + readerContext.extension = originalExtension; + readerContext.capability = readerContext.readerRuntime.negotiate(VoiceOfMLReader.capability(readerContext.extension)); + readerContext.readerRuntime.update("source", { extension: readerContext.extension }); + readerContext.updateDocumentState({ page: 1, pageCount: 0 }); + readerContext.formatAdapters.activate(readerContext.capability.mode); + readerContext.syncCapabilityControls(); + await readerContext.renderImageDocument(await readerContext.loadImageDocument(readerContext.downloadUrl)); + } + readerContext.assertReaderActive(); + readerContext.markReaderContentReady(); + const restored = await readerContext.awaitReader(restorationPromise); + await readerContext.applyInitialRestoration(restored, generation); + readerContext.assertReaderActive(); + if (!readerContext.setReaderPhase("ready")) return; + readerContext.updateDocumentState({ restorationReady: !readerContext.restorationFailed }); + readerContext.schedulePdfBookSearchPrefetch(); + readerContext.updateProgressTools(); + if (readerContext.fullSearchInput.value.trim()) readerContext.runFullSearch(); + readerContext.scheduleSave(); + } catch (error) { + if (readerContext.readerAbortController.signal.aborted || error?.name === "AbortError") return; + console.error(error); + if ( + error?.code !== "READER_RESTORE" && + readerContext.capability.mode === "epub-chapters" && + readerContext.validFallback(readerContext.fallbackUrl) + ) { + const target = new URL(location.href); + target.searchParams.set("url", readerContext.fallbackUrl); + target.searchParams.set("ext", "pdf"); + target.searchParams.delete("chapter_manifest"); + target.searchParams.delete("fallback"); + if (window.parent !== window) + window.parent.postMessage({ type: "voice-reader-open", url: target.href }, location.origin); + else location.replace(target.href); + return; + } + readerContext.fail( + error && error.message ? error.message : "原文件加载失败", + readerContext.classifyReaderError(error, readerContext.capability.mode === "foliate" ? "READER_CORRUPT" : "READER_PARSE") + ); + } + } + + function syncCapabilityControls() { + readerContext.content.dataset.mode = readerContext.capability.mode || "unsupported"; + readerContext.pageInvertToggle.hidden = !["pdf", "pdf-pages"].includes(readerContext.capability.mode); + document.querySelector(".page-controls").hidden = !readerContext.capability.features.pagination; + document.querySelector(".zoom-controls").hidden = !readerContext.capability.features.zoom; + readerContext.mediaTab.hidden = !readerContext.capability.features.media; + readerContext.fullSearchButton.hidden = !readerContext.capability.features.search || (readerContext.capability.mode === "pdf-pages" && !readerContext.ocrManifestUrl && !readerContext.pdfHybridUrl); + readerContext.bookmarkRibbon.hidden = !readerContext.capability.features.bookmarks; + readerContext.readingProgressSummary.querySelector(".reader-progress-bookmark").hidden = + !readerContext.capability.features.bookmarks; + if ( + (readerContext.panelState.selected === "media" && readerContext.mediaTab.hidden) || + (readerContext.panelState.selected === "full-search" && readerContext.fullSearchButton.hidden) + ) + readerContext.selectPanel("toc"); + } + + return { + disposeFormatResources, + registerReaderFormatAdapters, + resolveReaderSource, + start, + syncCapabilityControls + }; +} diff --git a/static/reader/formats/reader-html-format.mjs b/static/reader/formats/reader-html-format.mjs new file mode 100644 index 0000000000000000000000000000000000000000..117a65623bae15af2008be0874102db27a9556ad --- /dev/null +++ b/static/reader/formats/reader-html-format.mjs @@ -0,0 +1,322 @@ +export function createReaderHtmlFormat(readerContext) { + function normalizeEpubDocument(doc) { + // Legacy CHM conversions can use an XHTML prefix even in HTML content. + for (const element of [...doc.querySelectorAll("*")]) { + const tagName = String(element.tagName || "").toLowerCase(); + const localName = tagName.includes(":") ? tagName.slice(tagName.lastIndexOf(":") + 1) : tagName; + if (tagName === localName || !readerContext.EPUB_HTML_TAGS.has(localName)) continue; + const replacement = doc.createElementNS("http://www.w3.org/1999/xhtml", localName); + for (const attribute of [...element.attributes]) { + if (!attribute.name.toLowerCase().startsWith("xmlns:")) + replacement.setAttribute(attribute.name, attribute.value); + } + // HTML parsing treats prefixed void tags as containers. Keep the following + // content as siblings: serializing real
/
/ drops their children. + // Move the existing text nodes so Foliate's source/CFI mapping stays intact. + const fragment = doc.createDocumentFragment(); + fragment.appendChild(replacement); + if (readerContext.EPUB_HTML_VOID_TAGS.has(localName)) { + while (element.firstChild) fragment.appendChild(element.firstChild); + } else { + while (element.firstChild) replacement.appendChild(element.firstChild); + } + element.replaceWith(fragment); + } + return doc; + } + + function sanitizeReaderDocument(doc, { preserveEpubStyles = false } = {}) { + const blocked = preserveEpubStyles + ? "script,iframe,object,embed,base,form" + : "script,iframe,object,embed,base,form,link,style"; + for (const node of doc.querySelectorAll(blocked)) node.remove(); + if (preserveEpubStyles) { + for (const link of doc.querySelectorAll("link")) { + const rel = (link.getAttribute("rel") || "").toLowerCase().split(/\s+/); + if (!rel.includes("stylesheet") || !/^blob:/i.test(link.getAttribute("href") || "")) + link.remove(); + } + for (const style of doc.querySelectorAll("style")) { + if ( + /(?:@import\s+|url\s*\(\s*[\"']?)(?:https?:|\/\/|javascript:)/i.test( + style.textContent || "" + ) + ) + style.remove(); + } + } + for (const element of doc.querySelectorAll("*")) + for (const attribute of [...element.attributes]) { + const name = attribute.name.toLowerCase(), + value = String(attribute.value || "").trim(); + if ( + name.startsWith("on") || + name === "srcdoc" || + ((name === "href" || name === "xlink:href" || name === "src") && + /^javascript:/i.test(value)) + ) + element.removeAttribute(attribute.name); + } + return doc; + } + + function sanitizeEpubDocument(doc) { + return readerContext.sanitizeReaderDocument(readerContext.normalizeEpubDocument(doc), { preserveEpubStyles: true }); + } + + function parseChapterDocument(text) { + // Parse generated XHTML before HTML's void-element rules can swallow children + // of prefixed elements such as (including the following title). + const xml = new DOMParser().parseFromString(text, "application/xhtml+xml"); + if (!xml.querySelector("parsererror")) + text = new XMLSerializer().serializeToString(readerContext.normalizeEpubDocument(xml)); + return readerContext.sanitizeEpubDocument(new DOMParser().parseFromString(text, "text/html")); + } + + function epubContentBody(doc) { + const nestedBody = [...doc.querySelectorAll("body")].find((body) => body !== doc.body); + return nestedBody || doc.body || doc.documentElement; + } + + async function renderText(markdown, prepared) { + let response = await prepared.response; + if (!response.ok) response = await readerContext.fetchReaderResponse(); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!markdown) await readerContext.renderPlainText(response); + else { + const bytes = await readerContext.readPreparedBytes(response, prepared.engines); + const text = new TextDecoder(readerContext.detectTextEncoding(bytes, readerContext.documentState.title)).decode(bytes); + const article = document.createElement("article"); + article.className = "reader-markdown"; + article.innerHTML = DOMPurify.sanitize(marked.parse(text), { USE_PROFILES: { html: true } }); + readerContext.content.appendChild(article); + readerContext.setToc(readerContext.headingTocEntries(article)); + } + readerContext.markReaderContentReady(); + readerContext.status.textContent = "已加载"; + } + + function sanitizeOfflineHtml(text) { + const clean = DOMPurify.sanitize(text, { + USE_PROFILES: { html: true }, + ADD_TAGS: ["style"], + FORBID_TAGS: ["base", "embed", "form", "iframe", "object", "script"], + FORBID_ATTR: ["action", "formaction", "srcdoc"], + ALLOWED_URI_REGEXP: /^data:image\/(?:gif|png|jpeg|webp|svg\+xml);/i + }); + const template = document.createElement("template"); + template.innerHTML = clean; + const stripUrls = (value) => value.replace(/@import[^;]+;|url\s*\([^)]*\)/gi, ""); + for (const style of template.content.querySelectorAll("style")) + style.textContent = stripUrls(style.textContent); + for (const element of template.content.querySelectorAll("[style]")) + element.setAttribute("style", stripUrls(element.getAttribute("style"))); + return template.innerHTML; + } + + function buildDiscourseDocument(text) { + try { + const source = new DOMParser().parseFromString(text, "text/html"); + const posts = [...source.querySelectorAll(readerContext.DISCOURSE_POST_SELECTOR)]; + if (!source.querySelector(".post-stream") || !posts.length) return null; + const article = source.createElement("article"); + article.className = "reader-forum-document"; + const titleNode = source.querySelector(readerContext.DISCOURSE_TITLE_SELECTOR); + const title = titleNode?.textContent?.trim(); + if (title) { + const heading = source.createElement("h1"); + heading.textContent = title; + article.appendChild(heading); + } + posts.forEach((post, index) => { + const section = source.createElement("section"); + section.className = "reader-forum-post"; + section.dataset.post = String(index + 1); + section.innerHTML = post.innerHTML; + article.appendChild(section); + }); + return article.outerHTML; + } catch (_) { + return null; + } + } + + function prepareHtmlDocument(text) { + const discourse = readerContext.buildDiscourseDocument(text); + const html = readerContext.sanitizeOfflineHtml(discourse || text); + return { + html, + discourse: !!discourse, + spreadsheet: !discourse && html.includes("reader-spreadsheet-sheet") + }; + } + + function buildHtmlFrameSource(documentData) { + return documentData.html + readerContext.HTML_FRAME_STYLE + + (documentData.discourse ? readerContext.DISCOURSE_HTML_STYLE : "") + + (documentData.spreadsheet ? readerContext.SPREADSHEET_HTML_STYLE : ""); + } + + async function waitForSpreadsheetImages(frame) { + if (frame.contentDocument) + await readerContext.settleReaderImages(frame.contentDocument, 30000); + } + + async function renderHtml(prepared) { + const bytes = await readerContext.readPreparedBytes(prepared.response, prepared.engine); + const text = new TextDecoder(readerContext.detectHtmlEncoding(bytes, readerContext.documentState.title)).decode(bytes); + readerContext.assertReaderActive(); + const documentData = readerContext.prepareHtmlDocument(text); + const frame = document.createElement("iframe"); + readerContext.htmlFrame = frame; + frame.className = "html-frame"; + readerContext.content.classList.toggle("reader-content--spreadsheet", documentData.spreadsheet); + if (documentData.spreadsheet) frame.classList.add("spreadsheet-html-frame"); + frame.title = `${readerContext.documentState.title} 正文`; + frame.setAttribute("sandbox", "allow-same-origin"); + frame.setAttribute("referrerpolicy", "no-referrer"); + frame.style.colorScheme = "only light"; + const frameLoaded = new Promise((resolve, reject) => { + let untrack = () => {}, frameTimer = 0, deadline = 0, started = false; + const cleanup = () => { + clearTimeout(frameTimer); + clearTimeout(deadline); + frame.removeEventListener("load", loaded); + untrack(); + }; + const loaded = async () => { + if (started || frame.contentDocument?.URL !== "about:srcdoc" || frame.contentDocument.readyState === "loading") return; + started = true; + cleanup(); + try { + readerContext.assertReaderActive(); + readerContext.repairHtmlContrast(frame); + frame.contentDocument.documentElement.style.zoom = String(readerContext.documentState.zoom); + frame.contentWindow.addEventListener("scroll", readerContext.handleReaderPositionChange, { + passive: true + }); + frame.contentDocument.addEventListener("keydown", readerContext.handleReaderKeydown); + readerContext.installReaderTapSelectionClear(frame.contentDocument.body, frame.contentDocument); + for (const type of ["wheel", "touchstart", "pointerdown"]) + frame.contentDocument.addEventListener(type, readerContext.beginReaderNavigation, { passive: true }); + readerContext.setToc(readerContext.headingTocEntries(frame.contentDocument)); + if (documentData.spreadsheet) await readerContext.waitForSpreadsheetImages(frame); + readerContext.assertReaderActive(); + resolve(); + } catch (error) { + reject(error); + } + }; + untrack = readerContext.trackReaderResource(() => { + cleanup(); + reject(readerContext.readerAbortError()); + }); + const poll = () => { + if (started) return; + loaded(); + if (!started) frameTimer = setTimeout(poll, 16); + }; + frameTimer = setTimeout(poll, 0); + deadline = setTimeout(() => { cleanup(); reject(new Error("HTML document initialization timeout")); }, 20000); + frame.addEventListener("load", loaded); + }); + frame.srcdoc = readerContext.buildHtmlFrameSource(documentData); + readerContext.content.appendChild(frame); + await frameLoaded; + readerContext.markReaderContentReady(); + readerContext.status.textContent = documentData.spreadsheet ? "表格" : "HTML"; + } + + function detectHtmlEncoding(bytes, hint = "") { + const probe = String.fromCharCode(...bytes.subarray(0, 8192)); + const match = probe.match(/charset\s*=\s*["']?\s*([a-z0-9._:-]+)/i); + if (match) { + const label = + { gb2312: "gb18030", "gb-2312": "gb18030", gbk: "gb18030", "x-gbk": "gb18030" }[ + match[1].toLowerCase() + ] || match[1]; + try { + new TextDecoder(label); + return label; + } catch (_) {} + } + return readerContext.detectTextEncoding(bytes, hint); + } + + function repairHtmlContrast(frame) { + const doc = frame.contentDocument; + if (!doc || !doc.body) return; + const parseColor = (value) => { + const parts = String(value).match(/[\d.]+/g); + return parts && parts.length >= 3 + ? [ + Number(parts[0]), + Number(parts[1]), + Number(parts[2]), + parts[3] === undefined ? 1 : Number(parts[3]) + ] + : null; + }; + const luminance = (color) => { + const channels = color.slice(0, 3).map((value) => { + const normalized = value / 255; + return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; + }); + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; + }; + const backgrounds = new Map(); + const background = (element, style) => { + const pending = []; + let light = 1; + for (let current = element; current; current = current.parentElement) { + if (backgrounds.has(current)) { + light = backgrounds.get(current); + break; + } + pending.push(current); + const color = parseColor((current === element ? style : + frame.contentWindow.getComputedStyle(current)).backgroundColor); + if (color && color[3] > 0.1) { + light = luminance(color); + break; + } + } + for (const current of pending) backgrounds.set(current, light); + return light; + }; + const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT); + const elements = new Set(); + while (walker.nextNode() && elements.size < 10000) + if (walker.currentNode.data.trim()) elements.add(walker.currentNode.parentElement); + for (const element of elements) { + const style = frame.contentWindow.getComputedStyle(element); + const foreground = parseColor(style.color); + const dark = background(element, style); + if (!foreground) continue; + const light = luminance(foreground); + const contrast = (Math.max(light, dark) + 0.05) / (Math.min(light, dark) + 0.05); + if (contrast < 3) { + element.style.setProperty("color", dark > 0.45 ? "#111" : "#f5f5f5", "important"); + // Inherited color and currentColor backgrounds can change after a repair. + backgrounds.clear(); + } + } + } + + return { + normalizeEpubDocument, + sanitizeReaderDocument, + sanitizeEpubDocument, + parseChapterDocument, + epubContentBody, + renderText, + sanitizeOfflineHtml, + buildDiscourseDocument, + prepareHtmlDocument, + buildHtmlFrameSource, + waitForSpreadsheetImages, + renderHtml, + detectHtmlEncoding, + repairHtmlContrast + }; +} diff --git a/static/reader/formats/reader-image-loader.mjs b/static/reader/formats/reader-image-loader.mjs new file mode 100644 index 0000000000000000000000000000000000000000..77559cf8643eeb7574ad6b24b76264f54a10b6ba --- /dev/null +++ b/static/reader/formats/reader-image-loader.mjs @@ -0,0 +1,59 @@ +export function createReaderImageLoader({assertActive, abortError, trackResource, request, readBytes, timeoutMs}) { + function wait(image, target, timeoutMs, signal) { + assertActive(); + return new Promise((resolve, reject) => { + let settled = false, timeout = 0, untrack = () => {}; + const abort = () => finish(abortError()); + const finish = (error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + untrack(); + signal?.removeEventListener("abort", abort); + image.onload = image.onerror = null; + if (error) { + image.removeAttribute("src"); + reject(error); + } else resolve(image); + }; + untrack = trackResource(() => finish(abortError())); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) abort(); + if (settled) return; + timeout = setTimeout(() => finish(new DOMException("Reader image stalled", "TimeoutError")), timeoutMs); + image.onload = () => finish(); + image.onerror = () => finish(new Error("image load failed")); + if (image.src !== new URL(target, location.href).href) image.src = target; + if (image.complete && image.naturalWidth) finish(); + }); + } + + async function load(image, target, signal, deadline = performance.now() + timeoutMs) { + const remaining = () => Math.max(1, deadline - performance.now()); + const network = /^https?:$/.test(new URL(target, location.href).protocol); + try { + return await wait(image, target, Math.min(network ? 15000 : timeoutMs, remaining()), signal); + } catch (error) { + assertActive(); + signal?.throwIfAborted(); + if (!network || error?.name !== "TimeoutError" || !navigator.onLine) throw error; + } + const response = await request(target, remaining(), + { signal, priority: image.fetchPriority || "auto" }); + if (!response.ok) { + await response.body?.cancel(); + throw Object.assign(new Error(`HTTP ${response.status}`), {status: response.status}); + } + const bytes = await readBytes(response); + assertActive(); + signal?.throwIfAborted(); + const blobUrl = URL.createObjectURL(new Blob([bytes], {type: response.headers.get("Content-Type") || ""})); + try { + return await wait(image, blobUrl, Math.min(20000, remaining()), signal); + } finally { + URL.revokeObjectURL(blobUrl); + } + } + + return Object.freeze({load}); +} diff --git a/static/reader/formats/reader-media-format.mjs b/static/reader/formats/reader-media-format.mjs new file mode 100644 index 0000000000000000000000000000000000000000..f73e06a59e9b36929a86855d67511d62af8af840 --- /dev/null +++ b/static/reader/formats/reader-media-format.mjs @@ -0,0 +1,90 @@ +export function createReaderMediaFormat(readerContext) { + function formatMediaTime(seconds) { + if (!Number.isFinite(seconds) || seconds < 0) return "00:00"; + const total = Math.floor(seconds), + hours = Math.floor(total / 3600), + minutes = Math.floor((total % 3600) / 60), + secs = total % 60; + return `${hours ? `${String(hours).padStart(2, "0")}:` : ""}${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; + } + + function renderImageDocument(image) { + readerContext.assertReaderActive(); + readerContext.content.appendChild(image); + readerContext.markReaderContentReady(); + readerContext.status.textContent = "图片"; + } + + function renderMedia(mode) { + readerContext.assertReaderActive(); + const media = document.createElement(mode); + media.className = mode === "audio" ? "reader-audio" : "reader-video"; + media.controls = true; + media.setAttribute("aria-label", `${readerContext.documentState.title} ${mode === "audio" ? "音频" : "视频"}`); + media.preload = "metadata"; + if (mode === "video") media.playsInline = true; + const refresh = () => { + if (readerContext.readerAbortController.signal.aborted) return; + readerContext.mediaPanel.querySelector(".media-panel-time").textContent = + `${readerContext.formatMediaTime(media.currentTime)} / ${readerContext.formatMediaTime(media.duration)}`; + readerContext.scheduleMarkerSync(); + }; + for (const type of ["timeupdate", "durationchange", "loadedmetadata"]) + media.addEventListener(type, refresh, { passive: true }); + for (const type of ["pause", "ended", "seeked"]) + media.addEventListener(type, () => { + refresh(); + readerContext.saveProgress(); + }); + readerContext.mediaElement = media; + let sourceRetried = false; + const originalMode = VoiceOfMLReader.capability(readerContext.resolvedReaderData?.original_extension || readerContext.extension).mode; + const retryUrl = originalMode === mode && readerContext.validSource(readerContext.downloadUrl) ? readerContext.downloadUrl : readerContext.sourceUrl; + media.addEventListener("error", () => { + if (readerContext.readerAbortController.signal.aborted) return; + if (!sourceRetried && retryUrl !== media.src && readerContext.validSource(retryUrl)) { + sourceRetried = true; + media._readerSourceRetry = true; + media.src = retryUrl; + media.load(); + return; + } + media._readerSourceRetry = false; + readerContext.fail("媒体加载失败,请检查网络后重试,或下载原文件。", "READER_MEDIA"); + }); + media.src = readerContext.contentUrl; + readerContext.content.appendChild(media); + readerContext.markReaderContentReady(); + readerContext.status.textContent = mode === "audio" ? "音频" : "视频"; + } + + async function loadSwfDocument() { + await readerContext.loadScript(readerContext.RUFFLE_URL); + const response = await readerContext.fetchReaderResponse(); + return new Uint8Array(await response.arrayBuffer()); + } + + async function renderSwf(data) { + readerContext.assertReaderActive(); + const factory = window.RufflePlayer?.newest?.(); + if (!factory) throw Object.assign(new Error("Ruffle 未加载"), { code: "READER_SWF_ENGINE" }); + const player = factory.createPlayer(); + player.className = "reader-swf"; + player.setAttribute("aria-label", `${readerContext.documentState.title} Flash 内容`); + player.style.width = "100%"; + player.style.minHeight = "360px"; + readerContext.content.appendChild(player); + readerContext.swfPlayer = player; + await player.ruffle().load({ data }); + readerContext.markReaderContentReady(); + readerContext.status.textContent = "Flash 内容"; + } + + return { + formatMediaTime, + renderImageDocument, + renderMedia, + loadSwfDocument, + renderSwf + }; +} diff --git a/static/reader-section-virtualizer.js b/static/reader/formats/reader-section-virtualizer.js similarity index 100% rename from static/reader-section-virtualizer.js rename to static/reader/formats/reader-section-virtualizer.js diff --git a/static/reader/formats/reader-text-format.mjs b/static/reader/formats/reader-text-format.mjs new file mode 100644 index 0000000000000000000000000000000000000000..bdc1ff86438e9fd9ee52dd387d46e79f12b9efa1 --- /dev/null +++ b/static/reader/formats/reader-text-format.mjs @@ -0,0 +1,196 @@ +export function createReaderTextFormat(readerContext) { + async function renderPlainText(response) { + readerContext.assertReaderActive(); + const pre = document.createElement("div"); + pre.className = "reader-text"; + readerContext.content.appendChild(pre); + let block = null, blockLength = 0; + const appendDecoded = async value => { + const text = value.replace(/\ufffd/g, ""); + let offset = 0, deadline = performance.now() + 8; + while (offset < text.length) { + if (!block) { + block = document.createElement("pre"); + block.className = "reader-text-block"; + pre.appendChild(block); + } + let end = Math.min(text.length, offset + 32768 - blockLength); + if (end < text.length) { + const newline = text.lastIndexOf("\n", end - 1); + if (newline >= offset) end = newline + 1; + const previous = text.charCodeAt(end - 1), next = text.charCodeAt(end); + if ((previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) || + (previous === 13 && next === 10)) end--; + } + if (end === offset) { block = null; blockLength = 0; continue; } + const tail = block.lastChild; + if (tail?.nodeType === Node.TEXT_NODE) tail.appendData(text.slice(offset, end)); + else block.appendChild(document.createTextNode(text.slice(offset, end))); + if (text.length) readerContext.markReaderContentReady(); + blockLength += end - offset; + offset = end; + if (offset < text.length || blockLength >= 32768) { block = null; blockLength = 0; } + if (offset < text.length && performance.now() >= deadline) { + await readerContext.waitForReader(); + readerContext.assertReaderActive(); + deadline = performance.now() + 8; + } + } + }; + const limit = VoiceOfMLReaderSecurity.LIMITS.documentBytes; + VoiceOfMLReaderSecurity.assertResponseSize(response, limit); + if (!response.body?.getReader) { + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > limit) { + const error = new Error("READER_RESOURCE_LIMIT"); + error.code = "READER_RESOURCE_LIMIT"; + throw error; + } + readerContext.assertReaderActive(); + await appendDecoded(new TextDecoder(readerContext.detectTextEncoding(bytes, readerContext.documentState.title)).decode(bytes)); + return; + } + const reader = response.body.getReader(), + sampleChunks = [], + previewDecoder = new TextDecoder("utf-8"); + const untrack = readerContext.trackReaderResource(() => { + reader.cancel(readerContext.readerAbortError()).catch(() => {}); + }); + let length = 0, + sampleLength = 0, + displayedSampleSize = 0, + previewChunkIndex = 0, + previewChunkOffset = 0, + decoder = null, + asciiPreviewPossible = true, + asciiPrefixLength = 0; + const startDecoder = async () => { + const sample = new Uint8Array(sampleLength); + let offset = 0; + for (const chunk of sampleChunks) { + sample.set(chunk, offset); + offset += chunk.byteLength; + } + decoder = new TextDecoder(readerContext.detectTextEncoding(sample, readerContext.documentState.title)); + await appendDecoded(decoder.decode(sample.subarray(displayedSampleSize), { stream: true })); + sampleChunks.length = 0; + }; + try { + while (true) { + const { value, done } = await readerContext.awaitReader(reader.read()); + readerContext.assertReaderActive(); + if (done) break; + if (!value?.byteLength) continue; + length += value.byteLength; + if (length > limit) { + const error = new Error("READER_RESOURCE_LIMIT"); + error.code = "READER_RESOURCE_LIMIT"; + throw error; + } + if (!decoder) { + sampleChunks.push(value); + sampleLength += value.byteLength; + if (asciiPreviewPossible) { + let asciiLength = 0; + while (asciiLength < value.length && value[asciiLength] > 0 && value[asciiLength] < 128) + asciiLength++; + asciiPrefixLength += asciiLength; + if (value[asciiLength] === 0) { + // A split UTF-16 prefix must not leave an ASCII preview behind. + pre.textContent = ""; + block = null; + blockLength = 0; + displayedSampleSize = 0; + asciiPrefixLength = 0; + asciiPreviewPossible = false; + } else if (asciiLength < value.length) asciiPreviewPossible = false; + } + if (asciiPrefixLength >= 4 && asciiPrefixLength > displayedSampleSize) { + // The full sample remains for encoding detection; preview only its + // unseen ASCII prefix instead of revisiting all previous chunks. + while (displayedSampleSize < asciiPrefixLength) { + const chunk = sampleChunks[previewChunkIndex]; + const end = Math.min(chunk.length, previewChunkOffset + asciiPrefixLength - displayedSampleSize); + await appendDecoded(previewDecoder.decode(chunk.subarray(previewChunkOffset, end))); + displayedSampleSize += end - previewChunkOffset; + previewChunkOffset = end; + if (end === chunk.length) { + previewChunkIndex++; + previewChunkOffset = 0; + } + } + } + if (sampleLength < 65540) continue; + await startDecoder(); + } else { + await appendDecoded(decoder.decode(value, { stream: true })); + } + } + if (!decoder) await startDecoder(); + await appendDecoded(decoder.decode()); + } catch (reason) { + try { + await reader.cancel(reason); + } catch (_) {} + throw reason; + } finally { + untrack(); + reader.releaseLock(); + } + } + + function detectTextEncoding(bytes, hint = "") { + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return "utf-8"; + if (bytes[0] === 0xff && bytes[1] === 0xfe) return "utf-16le"; + if (bytes[0] === 0xfe && bytes[1] === 0xff) return "utf-16be"; + const evenNulls = bytes.filter((value, index) => !value && index % 2 === 0).length; + const oddNulls = bytes.filter((value, index) => !value && index % 2 === 1).length; + if (oddNulls > bytes.length / 8 && oddNulls > evenNulls * 4) return "utf-16le"; + if (evenNulls > bytes.length / 8 && evenNulls > oddNulls * 4) return "utf-16be"; + try { + new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true }); + return "utf-8"; + } catch (_) {} + if (/[\u0400-\u04ff]/.test(hint)) return "windows-1251"; + const candidates = /[\u3400-\u9fff]/.test(hint) + ? ["gb18030", "big5"] + : ["gb18030", "big5", "windows-1251", "windows-1252"]; + let best = "gb18030", + bestScore = -Infinity; + for (const encoding of candidates) { + try { + const text = new TextDecoder(encoding).decode(bytes); + const controls = (text.match(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g) || []).length; + const replacements = (text.match(/\ufffd/g) || []).length; + const cjk = (text.match(/[\u3400-\u9fff]/g) || []).length; + const cyrillic = (text.match(/[\u0400-\u04ff]/g) || []).length; + const commonCjk = ( + text.match( + /[的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经之进着等部家自理起现实都体制当本性应开合因由然前外政社义事相全与关各重新内正反明原利质向道命此变结解问意建公系军情者立代通题党程展料员革文总品活长求老基资级图统知组别期论运农指区战任处理世]/g + ) || [] + ).length; + const score = Math.max(cjk + commonCjk * 5, cyrillic) - controls * 20 - replacements * 40; + if (score > bestScore) { + best = encoding; + bestScore = score; + } + } catch (_) {} + } + return best; + } + + function renderTextDocument(prepared) { + return readerContext.renderText(false, prepared); + } + + function renderMarkdownDocument(prepared) { + return readerContext.renderText(true, prepared); + } + + return { + renderPlainText, + detectTextEncoding, + renderTextDocument, + renderMarkdownDocument + }; +} diff --git a/static/pdf-worker-wrapper.mjs b/static/reader/pdf/pdf-worker-wrapper.mjs similarity index 88% rename from static/pdf-worker-wrapper.mjs rename to static/reader/pdf/pdf-worker-wrapper.mjs index ed2c68e741fdc8183e593f64f45a6a3ff0a17830..a05046f9e1afeaecf27f301655c26e0379155cec 100644 --- a/static/pdf-worker-wrapper.mjs +++ b/static/reader/pdf/pdf-worker-wrapper.mjs @@ -19,12 +19,12 @@ if (!Math.sumPrecise) { }; } -import "./reader-resources.js"; +import "../../reader-resources.js"; const pendingMessages = []; const bufferMessage = event => pendingMessages.push(event); self.addEventListener("message", bufferMessage); -import(VoiceOfMLReaderResources.vendorUrl("pdfWorker", "./")).then(() => { +import(VoiceOfMLReaderResources.vendorUrl("pdfWorker", "../../")).then(() => { self.removeEventListener("message", bufferMessage); for (const event of pendingMessages) self.dispatchEvent(new MessageEvent("message", { data: event.data })); }); diff --git a/static/reader-engine-preload.js b/static/reader/pdf/reader-engine-preload.js similarity index 88% rename from static/reader-engine-preload.js rename to static/reader/pdf/reader-engine-preload.js index 25a4012acdfee52761f27638fd8dead8cf49f451..0c34c29be35ea92773926a2ca0663d1ead9baf69 100644 --- a/static/reader-engine-preload.js +++ b/static/reader/pdf/reader-engine-preload.js @@ -4,7 +4,7 @@ let pdf = params.get("ext")?.toLowerCase() === "pdf"; try { pdf ||= new URL(params.get("url")).pathname.toLowerCase().endsWith(".pdf"); } catch (_) {} if (!pdf) return; - const base = new URL(".", document.currentScript.src).href; + const base = new URL("../../", document.currentScript.src).href; for (const name of ["pdf", "pdfWorker"]) { const link = document.createElement("link"); link.rel = "modulepreload"; diff --git a/static/reader/pdf/reader-pdf-engine.mjs b/static/reader/pdf/reader-pdf-engine.mjs new file mode 100644 index 0000000000000000000000000000000000000000..0bb4964247d32fc113d028e831feebbf1d8601c5 --- /dev/null +++ b/static/reader/pdf/reader-pdf-engine.mjs @@ -0,0 +1,187 @@ +export function createReaderPdfEngine(readerContext) { + function loadPdfTaskWithTimeout(pdfjs, options, url) { + readerContext.assertReaderActive(); + const task = pdfjs.getDocument(options(url)); + return new Promise((resolve, reject) => { + let timeout = 0, + destroyed = false; + const destroy = () => { + if (destroyed) return; + destroyed = true; + clearTimeout(timeout); + try { + task.destroy()?.catch?.(() => {}); + } catch (_) {} + reject(readerContext.readerAbortError()); + }; + const untrack = readerContext.trackReaderResource(destroy); + timeout = setTimeout(() => { + reject(new Error("reader PDF timeout")); + untrack(); + destroy(); + }, readerContext.READER_PROXY_TIMEOUT_MS); + task.promise.then( + (document) => { + clearTimeout(timeout); + if (readerContext.readerAbortController.signal.aborted) { + destroy(); + return; + } + resolve(document); + }, + (error) => { + clearTimeout(timeout); + reject(error); + untrack(); + destroy(); + } + ); + }); + } + + function loadPdfWithTimeout(pdfjs, options, originalUrl = readerContext.sourceUrl, proxyUrl = readerContext.contentUrl) { + return readerContext.retryReaderProxy(() => readerContext.loadPdfTaskWithTimeout(pdfjs, options, proxyUrl)).catch((error) => { + readerContext.assertReaderActive(); + if (error && error.name === "AbortError") throw error; + if (readerContext.retryableReaderProxyError(error)) throw error; + return readerContext.loadPdfTaskWithTimeout(pdfjs, options, originalUrl); + }); + } + + function preloadPdfEngine() { + if (!readerContext.pdfEnginePreload) readerContext.pdfEnginePreload = readerContext.loadPdfEngine().then(readerContext.preparePdfWorker); + return readerContext.pdfEnginePreload; + } + + async function preparePdfWorker(pdfjs) { + for (let attempt = 0; attempt < 3; attempt++) { + readerContext.assertReaderActive(); + if (attempt) await readerContext.waitForReader(attempt * 500); + try { + return await readerContext.preparePdfWorkerAttempt(pdfjs, attempt); + } catch (error) { + readerContext.assertReaderActive(); + if (error?.code !== "READER_ENGINE_NETWORK" || attempt === 2) throw error; + } + } + } + + async function preparePdfWorkerAttempt(pdfjs, attempt) { + readerContext.assertReaderActive(); + if (readerContext.capability.mode !== "pdf" && !readerContext.pdfPrimarySource()) return pdfjs; + pdfjs.GlobalWorkerOptions.workerSrc = attempt + ? `${readerContext.PDFJS_WORKER_URL}?reader-worker-retry=${attempt}` : readerContext.PDFJS_WORKER_URL; + const worker = new pdfjs.PDFWorker(); + readerContext.pdfWorker = worker; + let timer, destroyed = false; + const destroy = () => { + if (destroyed) return; + destroyed = true; + clearTimeout(timer); + worker.destroy(); + if (readerContext.pdfWorker === worker) { + readerContext.pdfWorker = null; + readerContext.releasePdfWorker = null; + } + }; + readerContext.releasePdfWorker = destroy; + const untrack = readerContext.trackReaderResource(destroy); + try { + await readerContext.awaitReader(Promise.race([ + worker.promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("PDF worker timeout")), 20000); + }) + ])); + readerContext.assertReaderActive(); + return pdfjs; + } catch (error) { + untrack(); + destroy(); + readerContext.assertReaderActive(); + throw Object.assign(new Error("PDF 阅读组件下载失败,请检查网络后重试加载,或下载原文件。"), + { code: "READER_ENGINE_NETWORK", cause: error }); + } finally { + clearTimeout(timer); + } + } + + async function loadPdfEngine() { + for (let attempt = 0; attempt < 3; attempt++) { + readerContext.assertReaderActive(); + if (attempt) await readerContext.waitForReader(attempt * 500); + readerContext.assertReaderActive(); + // Browsers can retain a rejected import for the life of this document. + // Use two bounded retry URLs for the same pinned bytes, never a random URL. + const url = attempt ? `${readerContext.PDFJS_URL}?reader-module-retry=${attempt}` : readerContext.PDFJS_URL; + let timer; + try { + const pdfjs = await readerContext.awaitReader( + Promise.race([ + import(url), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new DOMException("PDF engine timeout", "TimeoutError")), + 20000 + ); + }) + ]) + ); + readerContext.assertReaderActive(); + return pdfjs; + } catch (error) { + readerContext.assertReaderActive(); + const networkFailure = + error?.name === "TimeoutError" || + /failed to fetch dynamically imported module|error loading dynamically imported module|importing a module script failed/i.test( + error?.message || "" + ); + if (!networkFailure) throw error; + if (attempt === 2) + throw Object.assign( + new Error("PDF 阅读组件下载失败,请检查网络后重试加载,或下载原文件。"), + { code: "READER_ENGINE_NETWORK", cause: error } + ); + } finally { + clearTimeout(timer); + } + } + } + + function loadPdfDocument(target = readerContext.sourceUrl) { + return readerContext.measurePdf(readerContext.preloadPdfEngine(), "engine").then((pdfjs) => { + readerContext.assertReaderActive(); + if (!readerContext.pdfFetchPolicy) { + readerContext.pdfFetchPolicy = readerContext.createPdfFetchPolicy(); + readerContext.trackReaderResource(() => { readerContext.pdfFetchPolicy?.dispose(); readerContext.pdfFetchPolicy = null; }); + } + readerContext.pdfFetchPolicy.add([target, readerContext.readerContentUrl(target)]); + pdfjs.GlobalWorkerOptions.workerSrc = readerContext.PDFJS_WORKER_URL; + const options = (url) => ({ + url, + worker: readerContext.pdfWorker, + // Bound demand reads for original and converted PDFs alike. Streaming + // otherwise keeps the full download running alongside these ranges. + rangeChunkSize: 262144, + disableAutoFetch: true, + disableStream: true, + wasmUrl: readerContext.PDFJS_WASM_URL, + cMapUrl: readerContext.PDFJS_CMAP_URL, + cMapPacked: true, + standardFontDataUrl: readerContext.PDFJS_STANDARD_FONT_URL, + withCredentials: false + }); + return readerContext.measurePdf(readerContext.loadPdfWithTimeout(pdfjs, options, target, readerContext.readerContentUrl(target)), "document"); + }); + } + + return { + loadPdfTaskWithTimeout, + loadPdfWithTimeout, + preloadPdfEngine, + preparePdfWorker, + preparePdfWorkerAttempt, + loadPdfEngine, + loadPdfDocument + }; +} diff --git a/static/reader-pdf-network.mjs b/static/reader/pdf/reader-pdf-network.mjs similarity index 100% rename from static/reader-pdf-network.mjs rename to static/reader/pdf/reader-pdf-network.mjs diff --git a/static/reader/pdf/reader-pdf-page-window.mjs b/static/reader/pdf/reader-pdf-page-window.mjs new file mode 100644 index 0000000000000000000000000000000000000000..453252b1e2ba7104789dc971a3073e1792b94646 --- /dev/null +++ b/static/reader/pdf/reader-pdf-page-window.mjs @@ -0,0 +1,590 @@ +export function createReaderPdfPageWindow(readerContext) { + async function preloadPdfFirstPage(total) { + if (readerContext.pdfV3Manifest) return; + await readerContext.getInitialReaderRestoration(); + try { + const source = readerContext.pdfManifestSource(); + if (!source) return; + const page = readerContext.initialReaderPage(total); + readerContext.pdfFirstPagePreloadPage = page; + const pageUrl = new URL(source.pageUrl(page)); + const early = window.__VOICE_PDF_PRELOAD__; + if (early?.image && early.pageUrl === pageUrl.href) { + readerContext.pdfFirstPagePreload = early.image; + // Reuse an existing request at demand priority when available. + readerContext.pdfFirstPagePreload.fetchPriority = "high"; + } + else if (!navigator.connection?.saveData) { + readerContext.pdfFirstPagePreload = new Image(); + readerContext.pdfFirstPagePreload.decoding = "async"; + readerContext.pdfFirstPagePreload.fetchPriority = "high"; + readerContext.pdfFirstPagePreload.src = pageUrl.href; + } + } catch (_) {} + } + + function pdfManifestSource() { + if (String(readerContext.sourceUrl).includes("reading-manifest.json")) + return VoiceOfMLReader.pdfPageSource(readerContext.sourceUrl, location.href, location.origin, "reading-manifest.json"); + return readerContext.capability.mode === "image-pages" + ? VoiceOfMLReader.imagePageSource(readerContext.sourceUrl, location.href, location.origin) + : VoiceOfMLReader.pdfPageSource(readerContext.sourceUrl, location.href, location.origin); + } + + function pdfPageEntry(page) { + if (!readerContext.pdfPageManifest || page < 1 || page > readerContext.pdfPageManifest.pageCount) return null; + const pageEntry = ( + readerContext.pdfPageManifest.entries?.[page - 1] || { + page, + path: `${readerContext.pdfPageManifest.root}/pages/page-${String(page).padStart(6, "0")}.webp` + } + ); + const ocrEntry = readerContext.pdfPageManifest.ocr?.[page - 1]; + return ocrEntry ? { ...pageEntry, ...ocrEntry, page } : pageEntry; + } + + async function appendShellsBatched(createShell, total, batchSize) { + for (let start = 2; start <= total; start += batchSize) { + readerContext.assertReaderActive(); + const fragment = document.createDocumentFragment(); + for (let page = start; page < Math.min(start + batchSize, total + 1); page++) + fragment.appendChild(createShell(page)); + readerContext.content.appendChild(fragment); + await readerContext.waitForReader(); + } + } + + function pdfSelectionIntersects(element) { + const selection = document.getSelection(); + if (!selection?.rangeCount) return false; + if (element.contains(selection.anchorNode) || element.contains(selection.focusNode)) return true; + return !selection.isCollapsed && selection.getRangeAt(0).intersectsNode(element); + } + + function correctPdfCrossPageSelection(x, y, start = null) { + const selection = document.getSelection(); + if (!selection?.rangeCount) return; + const anchorNode = start?.anchorNode || selection.anchorNode; + const anchorOffset = start?.anchorNode ? start.anchorOffset : selection.anchorOffset; + if (!start?.anchorNode && selection.isCollapsed) return; + const origin = anchorNode?.parentElement?.closest(".reader-page"); + let target = document.elementFromPoint(x, y)?.closest(".reader-page"); + const caret = start?.anchorNode && readerContext.independentTextCaret(document, x, y); + if (caret && origin?.contains(anchorNode) && readerContext.content.contains(caret.node)) { + selection.setBaseAndExtent(anchorNode, anchorOffset, caret.node, caret.offset); + return; + } + if (!target) { + let distance = Infinity; + for (const shell of readerContext.content.querySelectorAll(".reader-page")) { + const rect = shell.getBoundingClientRect(); + const gap = Math.max(rect.top - y, y - rect.bottom, 0); + if (gap < distance) { distance = gap; target = shell; } + } + } + if (!origin || !target || origin === target || !readerContext.content.contains(origin) || !readerContext.content.contains(target)) return; + const runs = [...target.querySelectorAll(".reader-pdf-text-run")].filter((run) => run.firstChild?.nodeType === Node.TEXT_NODE); + if (!runs.length) return; + let nearest = null, distance = Infinity; + for (const run of runs) { + const rect = run.getBoundingClientRect(); + const dx = Math.max(rect.left - x, x - rect.right, 0); + const dy = Math.max(rect.top - y, y - rect.bottom, 0); + const gap = Math.hypot(dx, dy); + if (gap < distance) { nearest = run; distance = gap; } + } + const node = nearest.firstChild, rect = nearest.getBoundingClientRect(); + let offset; + if (y < rect.top || y > rect.bottom) { + offset = y < rect.top ? 0 : node.length; + } else { + const range = document.createRange(); + const vertical = getComputedStyle(nearest).writingMode.startsWith("vertical"); + const position = vertical ? y : x; + let low = 0, high = node.length; + while (low < high) { + const mid = Math.floor((low + high) / 2); + range.setStart(node, mid); + range.setEnd(node, mid + 1); + const glyph = range.getBoundingClientRect(); + const center = vertical ? (glyph.top + glyph.bottom) / 2 : (glyph.left + glyph.right) / 2; + if (center < position) low = mid + 1; + else high = mid; + } + offset = low; + } + selection.setBaseAndExtent(anchorNode, anchorOffset, node, offset); + } + + function createPdfShellWindow(total, firstShell, createShell, unobserve, defaultRatio) { + if (total <= 256) return null; + const chunkSize = 32, chunks = [], ratios = new Map(); + let frame = 0, disposed = false; + const readWidth = () => { + const style = getComputedStyle(readerContext.content); + return (readerContext.content.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)) * readerContext.documentState.zoom; + }; + const chunkHeight = (chunk, width) => { + let height = 0; + for (let page = chunk.start; page <= chunk.end; page++) + height += Math.max(160, width * (ratios.get(page) || defaultRatio)) + 18; + return height; + }; + const fragment = document.createDocumentFragment(); + const initialWidth = readWidth(); + for (let start = 1; start <= total; start += chunkSize) { + const element = document.createElement("div"); + element.className = "reader-pdf-chunk"; + element.setAttribute("aria-hidden", "true"); + const chunk = { element, start, end: Math.min(total, start + chunkSize - 1), mounted: false }; + chunks.push(chunk); + element.style.height = `${chunkHeight(chunk, initialWidth)}px`; + fragment.appendChild(element); + } + readerContext.content.appendChild(fragment); + function mount(chunk) { + if (chunk.mounted) return; + const pages = document.createDocumentFragment(); + for (let page = chunk.start; page <= chunk.end; page++) { + const shell = page === 1 && firstShell.isConnected ? firstShell : createShell(page); + if (ratios.has(page)) shell.style.aspectRatio = `1 / ${ratios.get(page)}`; + pages.appendChild(shell); + } + chunk.element.style.height = ""; + chunk.element.removeAttribute("aria-hidden"); + chunk.element.appendChild(pages); + chunk.mounted = true; + } + function canUnmount(chunk) { + return chunk.mounted && !chunk.element.contains(document.activeElement) && + !readerContext.pdfSelectionIntersects(chunk.element); + } + function measureChunk(chunk) { + for (const shell of chunk.element.children) { + const bounds = shell.getBoundingClientRect(); + if (bounds.width && bounds.height) ratios.set(Number(shell.dataset.page), bounds.height / bounds.width); + } + } + function unmount(chunk, width) { + if (!chunk.mounted || chunk.element.contains(document.activeElement) || + readerContext.pdfSelectionIntersects(chunk.element)) return; + for (const shell of chunk.element.children) { + const page = Number(shell.dataset.page); + shell._textEpoch = (shell._textEpoch || 0) + 1; + shell._renderCancel?.(); + shell._hybridCancel?.(); + shell._v3PreviewRelease?.(); + unobserve(shell); + readerContext.pdfManifestShells[page] = undefined; + } + chunk.element.replaceChildren(); + chunk.element.style.height = `${chunkHeight(chunk, width)}px`; + chunk.element.setAttribute("aria-hidden", "true"); + chunk.mounted = false; + } + function show(index) { + if (disposed) return; + const anchor = readerContext.pageAtMarker(); + const before = readerContext.isPdfPageVisible(anchor) ? anchor.getBoundingClientRect().top : undefined; + const retiring = chunks.filter((chunk, i) => Math.abs(i - index) > 2 && canUnmount(chunk)); + const width = retiring.length ? readWidth() : 0; + // Capture all retiring page geometry before mounting or removing any chunks. + for (const chunk of retiring) measureChunk(chunk); + for (let i = Math.max(0, index - 2); i <= Math.min(chunks.length - 1, index + 2); i++) + mount(chunks[i]); + for (const chunk of retiring) unmount(chunk, width); + if (anchor?.isConnected && before !== undefined) + readerContext.viewport.scrollTop += anchor.getBoundingClientRect().top - before; + } + function sync() { + frame = 0; + if (disposed) return; + const marker = readerContext.viewport.scrollTop + readerContext.viewport.clientHeight / 2; + const index = chunks.findIndex((chunk) => chunk.element.offsetTop + chunk.element.offsetHeight > marker); + show(index < 0 ? chunks.length - 1 : index); + } + mount(chunks[0]); + const resizeObserver = new ResizeObserver(() => resize()); + resizeObserver.observe(readerContext.viewport); + function resize() { + if (disposed) return; + const anchor = readerContext.pageAtMarker(); + const before = readerContext.isPdfPageVisible(anchor) ? anchor.getBoundingClientRect().top : undefined; + const width = readWidth(); + for (const chunk of chunks) if (!chunk.mounted) chunk.element.style.height = `${chunkHeight(chunk, width)}px`; + if (anchor?.isConnected && before !== undefined) + readerContext.viewport.scrollTop += anchor.getBoundingClientRect().top - before; + schedule(); + } + function schedule() { + if (!disposed && !frame) frame = requestAnimationFrame(sync); + } + const window = { + ensure(page) { + const index = Math.floor((page - 1) / chunkSize); + show(index); + return chunks[index].element.querySelector(`.reader-page[data-page="${page}"]`); + }, + remember(shell) { + if (!shell.isConnected) return; + const bounds = shell.getBoundingClientRect(); + if (bounds.width && bounds.height) ratios.set(Number(shell.dataset.page), bounds.height / bounds.width); + }, + schedule, + resize, + dispose() { + disposed = true; + if (frame) cancelAnimationFrame(frame); + resizeObserver.disconnect(); + chunks.length = 0; + ratios.clear(); + readerContext.pdfShellWindow = null; + } + }; + readerContext.trackReaderResource(() => window.dispose()); + return window; + } + + async function renderPdfPages(prepared) { + const response = await prepared; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const decoded = JSON.parse(await VoiceOfMLReaderSecurity.readText(response, VoiceOfMLReaderSecurity.LIMITS.manifestBytes)); + const manifest = decoded?.kind === "pdf-reading" + ? readerContext.validateReadingManifest(decoded, VoiceOfMLReaderSecurity.LIMITS.pdfPages) + : VoiceOfMLReaderSecurity.validatePdfPageManifest(decoded); + if (manifest.kind === "pdf-reading") { + if (readerContext.pdfManifestSource()?.root.split("/").slice(0, 3).join("/") !== + `objects/${manifest.source_sha256.slice(0, 2)}/${manifest.source_sha256}`) + throw new Error("PDF_V3_SOURCE_MISMATCH"); + readerContext.pdfV3Repository = readerContext.createPdfV3Repository(manifest.source_sha256, manifest.page_count); + readerContext.pdfV3Map = readerContext.validatePageMap(await readerContext.pdfV3Repository.read(manifest.page_map), manifest); + readerContext.pdfV3Manifest = manifest; + readerContext.pdfOcrManifest = manifest.text_layer ? manifest : null; + readerContext.ocrManifestUrl = manifest.text_layer ? readerContext.sourceUrl : ""; + } + readerContext.assertReaderActive(); + const source = readerContext.pdfManifestSource(); + const totalPages = manifest.page_count; + if (!source) throw new Error("PDF_MANIFEST_INVALID"); + readerContext.pdfPageManifest = { ...source, pageCount: totalPages }; + if (readerContext.pdfV3Manifest) readerContext.pdfPageManifest.pageUrl = () => ""; + readerContext.pdfHybridUrl = readerContext.pdfPrimarySource(); + if (readerContext.pdfHybridUrl) { + readerContext.pdfHybridDocumentPromise = readerContext.loadPdfDocument(readerContext.pdfHybridUrl).then((pdf) => { + readerContext.assertReaderActive(); + if (pdf.numPages !== totalPages) throw new Error("PDF_PRIMARY_PAGE_COUNT_MISMATCH"); + readerContext.pdfDocument = pdf; + readerContext.clearPdfManifestPrefetches(); + readerContext.syncCapabilityControls(); + return pdf; + }); + readerContext.pdfHybridDocumentPromise.catch((error) => { + if (error?.name !== "AbortError" && !readerContext.readerLifecycle.disposed) + console.warn("Primary PDF unavailable; page stream retained", error); + }); + } + readerContext.updateDocumentState({ pageCount: totalPages }); + readerContext.pageInput.max = String(totalPages); + document.querySelector("#page-total").textContent = `/ ${totalPages}`; + readerContext.status.textContent = `${totalPages} 页`; + await readerContext.getInitialReaderRestoration(); + const initialPage = readerContext.initialReaderPage(totalPages); + if (readerContext.ocrManifestUrl && !navigator.connection?.saveData) { + readerContext.loadPdfOcrPage(initialPage).catch(() => {}); + } + await readerContext.preloadPdfFirstPage(totalPages); + let initialRenderGate = true; + if (Array.isArray(manifest.toc) && manifest.toc.length) + readerContext.setToc( + manifest.toc.map((item) => ({ + label: `${item.title} · 第 ${item.page} 页`, + depth: item.depth, + activate: (generation) => readerContext.goToPage(item.page, generation) + })) + ); + const observer = new IntersectionObserver( + (items) => { + if (readerContext.readerAbortController.signal.aborted) return; + for (const entry of items) if (entry.isIntersecting) { + entry.target.dataset.renderVisible = readerContext.isPdfPageVisible(entry.target) ? "1" : "0"; + readerContext.renderPdfInBackground(entry.target); + } + }, + { root: readerContext.viewport, rootMargin: `${readerContext.PDF_PREFETCH_MARGIN}px 0px` } + ); + const visibleObserver = new IntersectionObserver( + (items) => { + if (readerContext.readerAbortController.signal.aborted) return; + for (const entry of items) { + entry.target.dataset.renderVisible = entry.isIntersecting ? "1" : "0"; + if (entry.isIntersecting) { + readerContext.promotePdfRenderWaiter(entry.target); + readerContext.renderPdfInBackground(entry.target, false, readerContext.pdfPagePriority(entry.target)); + if (entry.target.dataset.renderState === "rendered") readerContext.renderPdfOcrText(entry.target); + } + } + }, + { root: readerContext.viewport, threshold: 0 } + ); + const observeShell = (shell) => { + if (initialRenderGate) { + shell._pdfObserversPending = true; + return; + } + observer.observe(shell); + visibleObserver.observe(shell); + }; + const releaseInitialRenderGate = () => { + initialRenderGate = false; + for (const shell of readerContext.content.querySelectorAll(".reader-page")) { + if (!shell._pdfObserversPending) continue; + delete shell._pdfObserversPending; + observeShell(shell); + } + }; + readerContext.trackReaderResource(() => { + observer.disconnect(); + visibleObserver.disconnect(); + }); + const createShell = (page) => { + const shell = document.createElement("section"); + shell.className = "reader-page"; + shell.dataset.page = String(page); + if (readerContext.pdfHybridUrl) shell.dataset.pdfHybrid = "1"; + shell.style.aspectRatio = "1 / 1.414"; + if (readerContext.pdfV3Map) { + const geometry = readerContext.pdfV3Map.pages[page - 1]; + shell.style.aspectRatio = `${geometry.width} / ${geometry.height}`; + } + readerContext.pdfManifestShells[page] = shell; + shell.tabIndex = 0; + shell.setAttribute("role", "region"); + shell.setAttribute("aria-label", `第 ${page} 页`); + const pageState = document.createElement("div"); + pageState.className = "reader-page-state"; + readerContext.setPdfPageLoadingState(pageState, page); + pageState.hidden = true; + const textLayer = document.createElement("div"); + textLayer.className = "reader-pdf-text"; + textLayer.setAttribute("role", "document"); + textLayer.setAttribute("aria-label", `第 ${page} 页正文`); + shell.append(pageState, textLayer); + if ( + page === readerContext.pdfFirstPagePreloadPage && + readerContext.pdfFirstPagePreload && + (!readerContext.pdfFirstPagePreload.complete || readerContext.pdfFirstPagePreload.naturalWidth) + ) + shell.appendChild(readerContext.pdfFirstPagePreload); + shell.addEventListener("focus", () => readerContext.renderPdfInBackground(shell)); + observeShell(shell); + return shell; + }; + const firstShell = createShell(1); + readerContext.content.appendChild(firstShell); + readerContext.markReaderContentReady(); + readerContext.pdfShellWindow = readerContext.createPdfShellWindow(totalPages, firstShell, createShell, (shell) => { + observer.unobserve(shell); + visibleObserver.unobserve(shell); + }, 1.414); + readerContext.pdfShellsReady = readerContext.pdfShellWindow ? Promise.resolve() : readerContext.appendShellsBatched(createShell, totalPages, 250); + readerContext.pdfShellsReady.catch(() => {}); + try { + const targetShell = readerContext.pdfShellWindow + ? readerContext.pdfShellWindow.ensure(initialPage) + : (await readerContext.pdfShellsReady, readerContext.content.querySelector(`.reader-page[data-page="${initialPage}"]`)); + if (!targetShell) throw new Error("PDF initial page shell missing"); + readerContext.viewport.scrollTop = readerContext.readerPageScrollTop(targetShell); + await readerContext.renderPdfManifestShell(targetShell, false, true); + readerContext.markReaderContentReady(); + readerContext.viewport.scrollTop = readerContext.readerPageScrollTop(targetShell); + } finally { + releaseInitialRenderGate(); + } + } + + async function renderImagePages(prepared) { + const response = await prepared; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const manifest = JSON.parse( + await VoiceOfMLReaderSecurity.readText(response, VoiceOfMLReaderSecurity.LIMITS.manifestBytes) + ); + if (!manifest || manifest.version !== 1 || manifest.kind !== "image-page-stream" || + !Number.isInteger(manifest.page_count) || manifest.page_count < 1 || + !Array.isArray(manifest.pages) || manifest.pages.length !== manifest.page_count) + throw new Error("IMAGE_MANIFEST_INVALID"); + const normalized = { + version: 2, + kind: "pdf-pages", + page_count: manifest.page_count, + entries: manifest.pages.map((entry, index) => ({ + page: index + 1, + path: entry.path, + bytes: entry.bytes, + sha256: entry.sha256 + })) + }; + return readerContext.renderPdfPages(Promise.resolve(new Response(JSON.stringify(normalized), { + headers: { "Content-Type": "application/json" } + }))); + } + + async function renderPdf(prepared) { + const pdf = await prepared; + readerContext.assertReaderActive(); + readerContext.pdfDocument = pdf; + readerContext.updateDocumentState({ pageCount: pdf.numPages }); + readerContext.pageInput.max = String(readerContext.documentState.pageCount); + document.querySelector("#page-total").textContent = `/ ${readerContext.documentState.pageCount}`; + readerContext.status.textContent = `${pdf.numPages} 页`; + await readerContext.getInitialReaderRestoration(); + const initialPage = readerContext.initialReaderPage(pdf.numPages); + let initialRenderGate = true; + const firstPage = await readerContext.measurePdf(readerContext.awaitReader(pdf.getPage(initialPage)), "page", initialPage); + readerContext.assertReaderActive(); + const firstViewport = firstPage.getViewport({ scale: 1 }); + const observer = new IntersectionObserver( + (entries) => + entries.forEach((entry) => { + if (readerContext.readerAbortController.signal.aborted) return; + entry.target.dataset.renderVisible = entry.isIntersecting && readerContext.isPdfPageVisible(entry.target) ? "1" : "0"; + if (entry.isIntersecting) readerContext.renderPdfInBackground(entry.target); + }), + { root: document.querySelector("#viewport"), rootMargin: `${readerContext.PDF_PREFETCH_MARGIN}px 0px` } + ); + const visibleObserver = new IntersectionObserver( + (entries) => + entries.forEach((entry) => { + if (readerContext.readerAbortController.signal.aborted) return; + entry.target.dataset.renderVisible = entry.isIntersecting ? "1" : "0"; + if (entry.isIntersecting) { + readerContext.promotePdfRenderWaiter(entry.target); + if (readerContext.pdfUserHasScrolled || entry.target.dataset.renderState !== "rendering") + readerContext.renderPdfInBackground(entry.target, false, readerContext.pdfPagePriority(entry.target)); + } + }), + { root: document.querySelector("#viewport"), threshold: 0 } + ); + const pageObserver = new IntersectionObserver(() => readerContext.scheduleMarkerSync(), { + root: document.querySelector("#viewport"), + threshold: [0, 0.5, 1] + }); + const observeShell = (shell) => { + if (initialRenderGate) { + shell._pdfObserversPending = true; + return; + } + observer.observe(shell); + visibleObserver.observe(shell); + pageObserver.observe(shell); + }; + const releaseInitialRenderGate = () => { + initialRenderGate = false; + for (const shell of readerContext.content.querySelectorAll(".reader-page")) { + if (!shell._pdfObserversPending) continue; + delete shell._pdfObserversPending; + observeShell(shell); + } + }; + readerContext.trackReaderResource(() => { + observer.disconnect(); + visibleObserver.disconnect(); + pageObserver.disconnect(); + }); + const createShell = (page) => { + const shell = document.createElement("section"); + shell.className = "reader-page"; + shell.dataset.page = String(page); + shell.style.aspectRatio = `${firstViewport.width} / ${firstViewport.height}`; + shell.tabIndex = 0; + shell.setAttribute("role", "region"); + shell.setAttribute("aria-label", `第 ${page} 页`); + const canvas = document.createElement("canvas"); + canvas.setAttribute("aria-hidden", "true"); + const textLayer = document.createElement("div"); + textLayer.className = "reader-pdf-text"; + textLayer.setAttribute("role", "document"); + textLayer.setAttribute("aria-label", `第 ${page} 页正文`); + shell.append(canvas, textLayer); + const pageState = document.createElement("div"); + pageState.className = "reader-page-state"; + readerContext.setPdfPageLoadingState(pageState, page); + shell.appendChild(pageState); + shell.addEventListener("focus", () => readerContext.renderPdfInBackground(shell)); + observeShell(shell); + return shell; + }; + const firstShell = createShell(1); + readerContext.content.appendChild(firstShell); + readerContext.pdfShellWindow = readerContext.createPdfShellWindow(pdf.numPages, firstShell, createShell, (shell) => { + observer.unobserve(shell); + visibleObserver.unobserve(shell); + pageObserver.unobserve(shell); + }, firstViewport.height / firstViewport.width); + readerContext.pdfShellsReady = readerContext.pdfShellWindow ? Promise.resolve() : readerContext.appendShellsBatched(createShell, pdf.numPages, 24); + try { + const targetShell = readerContext.pdfShellWindow + ? readerContext.pdfShellWindow.ensure(initialPage) + : (await readerContext.pdfShellsReady, readerContext.content.querySelector(`.reader-page[data-page="${initialPage}"]`)); + if (!targetShell) throw new Error("PDF initial page shell missing"); + readerContext.markReaderContentReady(); + readerContext.viewport.scrollTop = readerContext.readerPageScrollTop(targetShell); + await readerContext.renderPdfShell(targetShell, false, true); + readerContext.markReaderContentReady(); + readerContext.viewport.scrollTop = readerContext.readerPageScrollTop(targetShell); + } finally { + releaseInitialRenderGate(); + } + const outlineReady = (async () => { + if (typeof pdf.getOutline !== "function") return; + try { + const outline = await readerContext.awaitReader(pdf.getOutline()), + entries = []; + const cleanLabel = (value) => + String(value || "未命名章节") + .replace(/[\u2610-\u2612\u25a1]\s*[xX×]?\s*$/u, "") + .trim() || "未命名章节"; + const append = async (items, depth = 0) => { + for (const item of items || []) { + readerContext.assertReaderActive(); + let destination = item.dest, + page = 0; + if (typeof destination === "string") destination = await pdf.getDestination(destination); + if (destination && destination[0]) { + try { + page = (await pdf.getPageIndex(destination[0])) + 1; + } catch (_) {} + } + entries.push({ + label: page ? `${cleanLabel(item.title)} · 第 ${page} 页` : cleanLabel(item.title), + depth, + activate: (generation) => (page ? readerContext.goToPage(page, generation) : false) + }); + await append(item.items, depth + 1); + } + }; + await append(outline); + readerContext.assertReaderActive(); + if (entries.length) readerContext.setToc(entries); + } catch (error) { + if (!readerContext.readerAbortController.signal.aborted) + console.warn("PDF outline could not be loaded", error); + } + })(); + outlineReady.catch(() => {}); + await readerContext.pdfShellsReady; + readerContext.assertReaderActive(); + } + + return { + preloadPdfFirstPage, + pdfManifestSource, + pdfPageEntry, + appendShellsBatched, + pdfSelectionIntersects, + correctPdfCrossPageSelection, + createPdfShellWindow, + renderPdfPages, + renderImagePages, + renderPdf + }; +} diff --git a/static/reader/pdf/reader-pdf-renderer.mjs b/static/reader/pdf/reader-pdf-renderer.mjs new file mode 100644 index 0000000000000000000000000000000000000000..53ba136a8beb675d8ef380cf66a267a366f206bd --- /dev/null +++ b/static/reader/pdf/reader-pdf-renderer.mjs @@ -0,0 +1,442 @@ +export function createReaderPdfRenderer(readerContext) { + function setPdfPageLoadingState(pageState, page) { + pageState.dataset.state = "loading"; + pageState.setAttribute("role", "status"); + pageState.setAttribute("aria-label", `正在加载第 ${page} 页`); + pageState.replaceChildren(); + pageState.hidden = false; + } + + function isPdfPageVisible(shell) { + const pageRect = shell?.getBoundingClientRect?.(); + const viewportRect = readerContext.viewport?.getBoundingClientRect?.(); + return !!pageRect && !!viewportRect && pageRect.bottom > viewportRect.top && pageRect.top < viewportRect.bottom; + } + + function pdfPagePriority(shell) { + const pageRect = shell?.getBoundingClientRect?.(); + const viewportRect = readerContext.viewport?.getBoundingClientRect?.(); + if (!pageRect || !viewportRect) return 0; + if (pageRect.top >= viewportRect.top && pageRect.bottom <= viewportRect.bottom) return 3; + if (pageRect.bottom > viewportRect.top && pageRect.top < viewportRect.bottom) return 2; + return pageRect.bottom > viewportRect.top - readerContext.PDF_PREFETCH_MARGIN && + pageRect.top < viewportRect.bottom + readerContext.PDF_PREFETCH_MARGIN ? 1 : 0; + } + + function normalizePdfPriority(priority) { + return priority === true ? 3 : Math.max(0, Math.min(3, Number(priority) || 0)); + } + + function renderPdfInBackground(shell, force = false, priority = readerContext.pdfPagePriority(shell)) { + priority = readerContext.normalizePdfPriority(priority); + if (readerContext.readerAbortController.signal.aborted) return; + if (!force && shell.dataset.renderFailed === "1") return; + if (priority >= 2 && (force || shell.dataset.renderState !== "rendered") && + readerContext.pdfActiveRenders >= (matchMedia("(max-width: 700px)").matches ? 1 : 2)) + readerContext.cancelSpeculativePdfRenders(shell); + if (priority) { + shell.dataset.renderPriority = "1"; + readerContext.promotePdfRenderWaiter(shell); + if (["pdf-pages", "image-pages"].includes(readerContext.capability.mode) && readerContext.pdfPageManifest) + readerContext.prioritizePdfManifestPrefetch(readerContext.pdfPageManifest.pageUrl(Number(shell.dataset.page))); + } + if (shell._backgroundRender) { + if (priority >= 2 && shell._pdfRenderCancelled) shell._resumePdfDemand = true; + if (priority && ["pdf-pages", "image-pages"].includes(readerContext.capability.mode)) { + const image = shell.querySelector("img"); + if (image) image.fetchPriority = priority >= 3 ? "high" : "auto"; + } + if (force) { + shell.dataset.pendingRerender = "1"; + if (readerContext.pdfHybridUrl) shell.dataset.pendingHybridRerender = "1"; + } + return shell._backgroundRender; + } + const task = + ["pdf-pages", "image-pages"].includes(readerContext.capability.mode) + ? readerContext.renderPdfManifestShell(shell, force, priority) + : readerContext.renderPdfShell(shell, force, priority); + shell._backgroundRender = task; + task + .then(() => { delete shell.dataset.renderRetries; delete shell.dataset.renderFailed; }) + .catch((error) => { + if (readerContext.readerAbortController.signal.aborted || error?.name === "AbortError") return; + console.warn(`PDF page ${shell.dataset.page} render failed`, error); + const retries = Number(shell.dataset.renderRetries || 0); + const pageState = shell.querySelector(".reader-page-state"); + if (pageState && !shell.querySelector("canvas.ready, img.ready")) { + pageState.dataset.state = "error"; + pageState.removeAttribute("aria-label"); + pageState.hidden = false; + pageState.textContent = retries < 3 ? "页面加载失败,正在重试..." : "页面加载失败"; + } + if (retries < 3) { + shell.dataset.renderRetries = String(retries + 1); + readerContext.waitForReader(400 * (retries + 1)) + .then(() => { + if (shell.isConnected) readerContext.renderPdfInBackground(shell); + }) + .catch(() => {}); + } else { + shell.dataset.renderFailed = "1"; + if (pageState && !pageState.hidden) { + const retry = document.createElement("button"); + retry.type = "button"; + retry.textContent = "重试"; + retry.title = "重试加载此页"; + retry.addEventListener("click", () => { + delete shell.dataset.renderFailed; + delete shell.dataset.renderRetries; + readerContext.setPdfPageLoadingState(pageState, shell.dataset.page); + readerContext.renderPdfInBackground(shell, true, 3); + }, {once:true}); + pageState.append(retry); + } + } + }) + .finally(() => { + if (shell._backgroundRender === task) delete shell._backgroundRender; + delete shell._pdfRenderCancelled; + if (shell._resumePdfDemand) { + delete shell._resumePdfDemand; + if (!readerContext.readerAbortController.signal.aborted && shell.isConnected && readerContext.isPdfPageVisible(shell)) + queueMicrotask(() => readerContext.renderPdfInBackground(shell)); + } + }); + return task; + } + + function pdfPrimarySource() { + if (readerContext.capability.mode !== "pdf-pages") return ""; + if (readerContext.pdfV3Manifest) return readerContext.v3ResourceUrl(readerContext.pdfV3Manifest.primary.resource); + const candidate = readerContext.normalizeSourceUrl(readerContext.params.get("pdf_document") || readerContext.resolvedReaderData?.pdf_document || + (readerContext.resolvedReaderData?.original_extension === "pdf" || /\.pdf(?:$|[?#])/i.test(readerContext.downloadUrl) + ? readerContext.downloadUrl : "")); + try { + const parsed = new URL(candidate, location.href); + const path = parsed.searchParams.get("path") || parsed.pathname; + return readerContext.validSource(parsed.href) && /\.pdf$/i.test(path) ? parsed.href : ""; + } catch (_) { return ""; } + } + + function renderPdfManifestShell(shell, force = false, priority = false) { + if (!readerContext.pdfHybridUrl) return readerContext.renderPdfImageShell(shell, force, priority); + if (!force && shell.querySelector("canvas.ready")) return Promise.resolve(); + if (shell._hybridRenderPromise) { + if (force) shell.dataset.pendingHybridRerender = "1"; + return shell._hybridReadyPromise; + } + const controller = new AbortController(); + shell._hybridCancel = () => { controller.abort(); shell._renderCancel?.(); }; + const pdfTask = readerContext.renderHybridPdfCanvas(shell, controller.signal, priority); + const previewTask = (async () => { + if (readerContext.pdfDocument || shell.dataset.pdfPresented === "1") await readerContext.waitForReader(250); + if (controller.signal.aborted || !shell.isConnected) throw readerContext.readerAbortError(); + if (shell.querySelector("canvas.ready") || shell.dataset.pdfPresented === "1") return pdfTask; + if (!readerContext.normalizePdfPriority(priority) && readerContext.pdfDocument) return pdfTask; + const preview = await readerContext.renderPdfImageShell(shell, false, priority); + if (controller.signal.aborted) throw readerContext.readerAbortError(); + return preview; + })(); + const ready = Promise.any([pdfTask, previewTask]).catch((error) => { + if (controller.signal.aborted || readerContext.readerAbortController.signal.aborted) throw readerContext.readerAbortError(); + throw error.errors?.[1] || error.errors?.[0] || error; + }); + shell._hybridReadyPromise = ready; + const complete = Promise.allSettled([pdfTask, previewTask]).finally(() => { + if (shell._hybridRenderPromise === complete) { + delete shell._hybridRenderPromise; + delete shell._hybridReadyPromise; + delete shell._hybridCancel; + if (shell.dataset.pendingHybridRerender && shell.isConnected && !readerContext.readerLifecycle.disposed) { + delete shell.dataset.pendingHybridRerender; + readerContext.renderPdfInBackground(shell, true); + } + } + }); + shell._hybridRenderPromise = complete; + return ready; + } + + async function renderHybridPdfCanvas(shell, signal, priority) { + let acquired = false, canvas = null, rendering = null; + const untrack = readerContext.trackReaderResource(() => { rendering?.cancel?.(); shell._hybridCancel?.(); }); + const cancel = () => rendering?.cancel?.(); + signal.addEventListener("abort", cancel, { once: true }); + try { + const pdf = await readerContext.awaitReader(readerContext.pdfHybridDocumentPromise, signal); + do { + const generation = readerContext.readerRuntime.currentGeneration("pdf"); + if (signal.aborted || !shell.isConnected) throw readerContext.readerAbortError(); + const page = await readerContext.measurePdf(readerContext.awaitReader(pdf.getPage(Number(shell.dataset.page)), signal), "page", Number(shell.dataset.page)); + if (signal.aborted || !shell.isConnected) throw readerContext.readerAbortError(); + if (!acquired) { + await readerContext.measurePdf(readerContext.acquirePdfRenderSlot(priority, shell, signal), "queue", Number(shell.dataset.page)); + acquired = true; + shell._pdfRenderOwnsSlot = true; + } + const base = page.getViewport({ scale: 1 }); + if (readerContext.pdfV3Map) { + const geometry = readerContext.pdfV3Map.pages[Number(shell.dataset.page) - 1]; + if (Math.abs((base.width / base.height) / (geometry.width / geometry.height) - 1) > .01) + throw new Error("PDF_V3_DOCUMENT_GEOMETRY_MISMATCH"); + } + const view = page.getViewport({ scale: Math.min(3, shell.clientWidth / base.width) }); + const outputScale = Math.min(2, Math.max(1, Number(window.devicePixelRatio) || 1)); + canvas = document.createElement("canvas"); + canvas.setAttribute("aria-hidden", "true"); + canvas.width = Math.ceil(view.width * outputScale); + canvas.height = Math.ceil(view.height * outputScale); + rendering = page.render({ canvasContext: canvas.getContext("2d"), viewport: view, + transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0] }); + await readerContext.measurePdf(readerContext.awaitReader(rendering.promise, signal), "draw", Number(shell.dataset.page)); + rendering = null; + while (!readerContext.pdfV3Manifest && readerContext.pdfSelectionIntersects(shell) && !signal.aborted) await readerContext.waitForReader(100); + if (signal.aborted || !shell.isConnected) throw readerContext.readerAbortError(); + if (!readerContext.isReaderGenerationCurrent("pdf", generation)) { + canvas.width = canvas.height = 0; + continue; + } + const anchor = readerContext.content.querySelector(`.reader-page[data-page="${readerContext.documentState.page}"]`); + const oldTop = anchor?.getBoundingClientRect().top; + const oldHeight = anchor?.getBoundingClientRect().height || 1; + const fraction = anchor ? Math.max(0, (readerContext.viewport.getBoundingClientRect().top - oldTop) / oldHeight) : 0; + const previous = shell.querySelector("canvas"); + canvas.classList.add("ready"); + if (previous) { previous.replaceWith(canvas); previous.width = previous.height = 0; } + else shell.prepend(canvas); + shell.style.aspectRatio = `${base.width} / ${base.height}`; + shell._renderCancel?.(); + shell.querySelector("img")?.remove(); + shell._v3PreviewRelease?.(); + const pageState = shell.querySelector(".reader-page-state"); + if (pageState) pageState.hidden = true; + shell.dataset.pdfPresented = "1"; + delete shell.dataset.pendingHybridRerender; + shell.dataset.renderState = "rendered"; + delete shell.dataset.renderRetries; + delete shell.dataset.renderFailed; + shell.dataset.renderUsedAt = String(Date.now()); + if (anchor) { + const current = anchor.getBoundingClientRect(); + readerContext.viewport.scrollTop += current.top - oldTop + fraction * (current.height - oldHeight); + } + readerContext.pdfShellWindow?.remember(shell); + readerContext.trimPdfCanvases(shell); + (readerContext.ocrManifestUrl ? readerContext.renderPdfOcrText(shell) : readerContext.renderPdfText(page, shell, signal)).catch(() => {}); + readerContext.scheduleMarkerSync(); + canvas = null; + return; + } while (true); + } finally { + rendering?.cancel?.(); + if (canvas) canvas.width = canvas.height = 0; + if (acquired) { delete shell._pdfRenderOwnsSlot; readerContext.releasePdfRenderSlot(); } + signal.removeEventListener("abort", cancel); + untrack(); + } + } + + function renderPdfImageShell(shell, force = false, priority = false) { + if (!shell) return Promise.reject(new Error("PDF page shell missing")); + if (shell._renderPromise) return shell._renderPromise; + if (!force && shell.dataset.renderState === "rendered") return Promise.resolve(); + shell.dataset.renderState = "rendering"; + const renderController = new AbortController(); + const hybridPreview = !!readerContext.pdfHybridUrl; + shell._renderCancel = () => renderController.abort(); + shell._renderStarted = false; + priority = readerContext.normalizePdfPriority(priority || readerContext.pdfPagePriority(shell)); + shell._renderActivePriority = String(priority); + const task = (async () => { + let acquired = false; + try { + if (hybridPreview) { + while (readerContext.pdfHybridPreviewLoads >= 2) { + await readerContext.waitForReader(25); + if (renderController.signal.aborted) throw readerContext.readerAbortError(); + } + if (renderController.signal.aborted) throw readerContext.readerAbortError(); + readerContext.pdfHybridPreviewLoads++; + } else await readerContext.acquirePdfRenderSlot(priority, shell, renderController.signal); + acquired = true; + shell._renderStarted = true; + readerContext.assertReaderActive(); + const entry = readerContext.pdfPageEntry(Number(shell.dataset.page)); + if (!entry) throw new Error("PDF page entry missing"); + let target = readerContext.pdfPageManifest.pageUrl(entry.page); + if (readerContext.pdfV3Manifest) { + const preview = await readerContext.pdfV3Repository.preview(readerContext.pdfV3Manifest, readerContext.pdfV3Map, entry.page); + shell._v3PreviewEntry = preview.entry; + readerContext.assertReaderActive(); + if (renderController.signal.aborted) throw readerContext.readerAbortError(); + shell._v3PreviewRelease?.(); + target = URL.createObjectURL(preview.blob); + const release = () => { URL.revokeObjectURL(target); readerContext.pdfV3BlobReleases.delete(release); delete shell._v3PreviewRelease; }; + shell._v3PreviewRelease = release; + readerContext.pdfV3BlobReleases.add(release); + } + let loadText = null; + let image = shell.querySelector("img"); + const pageState = shell.querySelector(".reader-page-state"); + if (!image) { + image = readerContext.takePdfManifestPrefetch(target) || new Image(); + image.alt = `第 ${entry.page} 页`; + image.decoding = "async"; + shell.appendChild(image); + } + image.fetchPriority = priority >= 3 ? "high" : priority >= 1 ? "auto" : "low"; + if (priority >= 2 && readerContext.ocrManifestUrl && !navigator.connection?.saveData) + loadText = readerContext.renderPdfOcrText(shell); + loadText?.catch(() => {}); + await readerContext.imageLoader.load(image, target, renderController.signal); + readerContext.assertReaderActive(); + if (shell.dataset.pdfPresented === "1") return; + if (readerContext.pdfV3Manifest) { + const geometry = readerContext.pdfV3Map.pages[entry.page - 1]; + if (image.naturalWidth !== shell._v3PreviewEntry.width || image.naturalHeight !== shell._v3PreviewEntry.height || + Math.abs((image.naturalWidth / image.naturalHeight) / (geometry.width / geometry.height) - 1) > .01) + throw new Error("PDF_V3_PREVIEW_GEOMETRY_MISMATCH"); + } + shell.style.aspectRatio = `${image.naturalWidth || 1} / ${image.naturalHeight || 1}`; + if (priority >= 2 && readerContext.ocrManifestUrl && !navigator.connection?.saveData) + loadText ||= readerContext.renderPdfOcrText(shell); + loadText?.catch(() => {}); + image.classList.add("ready"); + if (pageState) pageState.hidden = true; + shell.dataset.renderState = "rendered"; + shell.dataset.renderUsedAt = String(Date.now()); + readerContext.pdfShellWindow?.remember(shell); + if (readerContext.isPdfPageVisible(shell) || !readerContext.pdfManifestPrefetchOrigin) readerContext.schedulePdfManifestPrefetch(entry.page); + else if (!readerContext.pdfManifestPrefetchActive.size && !readerContext.pdfManifestPrefetchTimer) readerContext.queuePdfManifestPrefetch(); + readerContext.trimPdfManifestImages(shell); + readerContext.scheduleMarkerSync(); + } catch (error) { + shell.querySelector("img")?.remove(); + shell._v3PreviewRelease?.(); + const pageState = shell.querySelector(".reader-page-state"); + if (pageState && shell.dataset.pdfPresented !== "1") { + pageState.dataset.state = "error"; + pageState.removeAttribute("aria-label"); + pageState.hidden = false; + pageState.textContent = "页面加载失败,正在重试…"; + } + if (shell.dataset.pdfPresented !== "1") shell.dataset.renderState = "idle"; + throw error; + } finally { + if (acquired) { + if (hybridPreview) readerContext.pdfHybridPreviewLoads--; + else readerContext.releasePdfRenderSlot(); + } + } + })().finally(() => { + if (shell._renderPromise === task) delete shell._renderPromise; + if (shell._renderCancel) delete shell._renderCancel; + delete shell._renderStarted; + delete shell._renderActivePriority; + }); + shell._renderPromise = task; + return task; + } + + function renderPdfShell(shell, force = false, priority = false) { + if (shell._renderPromise) { + if (force) shell.dataset.pendingRerender = "1"; + return shell._renderPromise; + } + if (!force && shell.dataset.renderState === "rendered") return Promise.resolve(); + shell.dataset.renderState = "rendering"; + const loadingState = shell.querySelector(".reader-page-state"); + if (loadingState && !shell.querySelector("canvas.ready, img.ready")) + readerContext.setPdfPageLoadingState(loadingState, shell.dataset.page); + const renderController = new AbortController(); + priority = readerContext.normalizePdfPriority(priority || readerContext.pdfPagePriority(shell)); + let activeRendering = null; + shell._renderCancel = () => { + renderController.abort(); + activeRendering?.cancel?.(); + }; + const task = (async () => { + let acquired = false; + try { + await readerContext.measurePdf(readerContext.acquirePdfRenderSlot(priority, shell, renderController.signal), "queue", Number(shell.dataset.page)); + acquired = true; + shell._pdfRenderOwnsSlot = true; + readerContext.assertReaderActive(); + if (renderController.signal.aborted) throw readerContext.readerAbortError(); + const pdf = readerContext.pdfDocument; + if (!pdf) throw readerContext.readerAbortError(); + do { + delete shell.dataset.pendingRerender; + const generation = readerContext.readerRuntime.currentGeneration("pdf"), + pageNumber = Number(shell.dataset.page), + page = await readerContext.measurePdf(readerContext.awaitReader(pdf.getPage(pageNumber), renderController.signal), "page", pageNumber); + readerContext.assertReaderActive(); + if (renderController.signal.aborted) throw readerContext.readerAbortError(); + const base = page.getViewport({ scale: 1 }), + scale = Math.min(3, shell.clientWidth / base.width), + viewport = page.getViewport({ scale }), + outputScale = Math.min(2, Math.max(1, Number(window.devicePixelRatio) || 1)); + const canvas = shell.querySelector("canvas"); + canvas.width = Math.ceil(viewport.width * outputScale); + canvas.height = Math.ceil(viewport.height * outputScale); + canvas.style.width = "100%"; + canvas.style.height = "auto"; + shell.style.aspectRatio = `${viewport.width} / ${viewport.height}`; + const rendering = page.render({ + canvasContext: canvas.getContext("2d"), + viewport, + transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0] + }); + activeRendering = rendering; + const untrack = readerContext.trackReaderResource(() => rendering.cancel?.()); + const textTask = readerContext.renderPdfText(page, shell, renderController.signal); + textTask.catch(() => {}); + try { + // The canvas is the first visible result. Text extraction can need a + // separate range and must not delay the visual first page. + await readerContext.measurePdf(readerContext.awaitReader(rendering.promise, renderController.signal), "draw", Number(shell.dataset.page)); + } finally { + untrack(); + activeRendering = null; + } + readerContext.assertReaderActive(); + if (renderController.signal.aborted) throw readerContext.readerAbortError(); + if (!readerContext.isReaderGenerationCurrent("pdf", generation)) shell.dataset.pendingRerender = "1"; + } while (shell.dataset.pendingRerender); + shell.querySelector("canvas").classList.add("ready"); + const pageState = shell.querySelector(".reader-page-state"); + if (pageState) pageState.hidden = true; + shell.dataset.renderState = "rendered"; + shell.dataset.renderUsedAt = String(Date.now()); + readerContext.pdfShellWindow?.remember(shell); + readerContext.trimPdfCanvases(shell); + delete shell.dataset.renderRetries; + delete shell.dataset.renderFailed; + } catch (error) { + shell.dataset.renderState = "idle"; + throw error; + } finally { + if (acquired) { delete shell._pdfRenderOwnsSlot; readerContext.releasePdfRenderSlot(); } + } + })().finally(() => { + if (shell._renderPromise === task) delete shell._renderPromise; + if (shell._renderCancel) delete shell._renderCancel; + }); + shell._renderPromise = task; + return task; + } + + return { + setPdfPageLoadingState, + isPdfPageVisible, + pdfPagePriority, + normalizePdfPriority, + renderPdfInBackground, + pdfPrimarySource, + renderPdfManifestShell, + renderHybridPdfCanvas, + renderPdfImageShell, + renderPdfShell + }; +} diff --git a/static/reader/pdf/reader-pdf-scheduler.mjs b/static/reader/pdf/reader-pdf-scheduler.mjs new file mode 100644 index 0000000000000000000000000000000000000000..93a97aed82ccadb3bc476b5b26aacd79356bc6b8 --- /dev/null +++ b/static/reader/pdf/reader-pdf-scheduler.mjs @@ -0,0 +1,381 @@ +export function createReaderPdfScheduler(readerContext) { + function pdfManifestPrefetchConcurrency() { + const type = navigator.connection?.effectiveType; + if (type === "slow-2g" || type === "2g") return 1; + if (type === "3g") return 1; + if (matchMedia("(max-width: 700px)").matches) return 2; + return readerContext.PDF_MANIFEST_PREFETCH_CONCURRENCY; + } + + function pdfManifestPrefetchMaxPages() { + const type = navigator.connection?.effectiveType; + if (type === "slow-2g" || type === "2g") return 8; + if (type === "3g") return 16; + return matchMedia("(max-width: 700px)").matches ? 24 : readerContext.PDF_MANIFEST_PREFETCH_MAX_PAGES; + } + + function canPrefetchPdfImages() { + const connection = navigator.connection; + return !connection?.saveData && !["slow-2g", "2g"].includes(connection?.effectiveType); + } + + function cancelPdfManifestPrefetch(record) { + if (!record) return; + readerContext.pdfManifestPrefetchActive.delete(record); + clearTimeout(record.timeout); + if (readerContext.pdfManifestPrefetches.get(record.url) === record) readerContext.pdfManifestPrefetches.delete(record.url); + record.image.onload = record.image.onerror = null; + record.image.removeAttribute("src"); + } + + function stopPdfManifestPrefetch() { + clearTimeout(readerContext.pdfManifestPrefetchTimer); + readerContext.pdfManifestPrefetchTimer = 0; + for (const record of [...readerContext.pdfManifestPrefetchActive]) readerContext.cancelPdfManifestPrefetch(record); + } + + function trimPdfManifestPrefetches() { + while (readerContext.pdfManifestPrefetches.size > readerContext.PDF_MANIFEST_PREFETCH_LIMIT || readerContext.pdfRetainedPixels() > readerContext.pdfPixelBudget()) { + const victim = [...readerContext.pdfManifestPrefetches.values()].find((record) => !readerContext.pdfManifestPrefetchActive.has(record)); + if (!victim) return; + readerContext.pdfManifestPrefetches.delete(victim.url); + victim.image.onload = victim.image.onerror = null; + victim.image.removeAttribute("src"); + } + } + + function pdfPixelBudget() { + const lowMemory = Number(navigator.deviceMemory) > 0 && Number(navigator.deviceMemory) <= 2; + return (lowMemory ? 32 : matchMedia("(max-width: 700px)").matches ? 48 : 96) * 1024 * 1024 / 4; + } + + function pdfSurfacePixels(surface) { + return surface?.tagName === "CANVAS" ? surface.width * surface.height : + (surface?.naturalWidth || 0) * (surface?.naturalHeight || 0); + } + + function pdfRetainedPixels() { + let pixels = 0; + for (const surface of readerContext.content.querySelectorAll(".reader-page > canvas, .reader-page > img")) + pixels += readerContext.pdfSurfacePixels(surface); + for (const record of readerContext.pdfManifestPrefetches.values()) pixels += readerContext.pdfSurfacePixels(record.image); + return pixels; + } + + function trimPdfSurfaces(protectedShell) { + readerContext.trimPdfManifestPrefetches(); + const shells = [...readerContext.content.querySelectorAll('.reader-page[data-render-state="rendered"]')]; + const canvasLimit = matchMedia("(max-width: 700px)").matches ? 7 : 11; + let canvases = shells.filter(shell => shell.querySelector("canvas.ready")).length; + let images = shells.filter(shell => shell.querySelector("img.ready")).length; + let pixels = readerContext.pdfRetainedPixels(); + const retentionPage = Number(readerContext.pageAtMarker()?.dataset.page) || readerContext.documentState.page; + const distance = shell => { + const delta = Number(shell.dataset.page) - retentionPage; + return Math.abs(delta) * (delta * readerContext.pdfScrollDirection < 0 ? 2 : 1); + }; + shells.sort((a, b) => distance(b) - distance(a) || + Number(a.dataset.renderUsedAt || 0) - Number(b.dataset.renderUsedAt || 0)); + for (const shell of shells) { + const canvas = shell.querySelector("canvas.ready"), image = shell.querySelector("img.ready"); + if (pixels <= readerContext.pdfPixelBudget() && !(canvas && canvases > canvasLimit) && !(image && images > 25)) continue; + if (readerContext.isPdfPageVisible(shell) || Number(shell.dataset.page) === retentionPage || readerContext.pdfSelectionIntersects(shell)) continue; + shell._textEpoch = (shell._textEpoch || 0) + 1; + if (canvas) { + pixels -= readerContext.pdfSurfacePixels(canvas); + canvas.width = canvas.height = 0; + canvas.classList.remove("ready"); + canvases--; + } + if (image) { + pixels -= readerContext.pdfSurfacePixels(image); + image.removeAttribute("src"); + image.remove(); + shell._v3PreviewRelease?.(); + images--; + } + shell.querySelector(".reader-pdf-text")?.replaceChildren(); + shell._bookmarkTextItems = []; + shell.dataset.textReady = "0"; + shell.dataset.renderState = "idle"; + const pageState = shell.querySelector(".reader-page-state"); + if (pageState) readerContext.setPdfPageLoadingState(pageState, shell.dataset.page); + } + readerContext.domSearch.pruneMarks(); + } + + function clearPdfManifestPrefetches() { + readerContext.stopPdfManifestPrefetch(); + readerContext.pdfManifestPrefetchNext = 0; + readerContext.pdfManifestPrefetchOrigin = 0; + readerContext.pdfManifestPrefetchCount = 0; + readerContext.pdfManifestPrefetchWrapped = false; + readerContext.pdfManifestShells.length = 0; + for (const { image } of readerContext.pdfManifestPrefetches.values()) { + image.onload = image.onerror = null; + image.removeAttribute("src"); + } + readerContext.pdfManifestPrefetches.clear(); + } + + function takePdfManifestPrefetch(target) { + const record = readerContext.pdfManifestPrefetches.get(target); + if (!record) return null; + readerContext.pdfManifestPrefetches.delete(target); + readerContext.pdfManifestPrefetchActive.delete(record); + clearTimeout(record.timeout); + record.image.onload = record.image.onerror = null; + return record.image; + } + + function queuePdfManifestPrefetch(delay = 250) { + if (readerContext.readerAbortController.signal.aborted || !readerContext.pdfManifestPrefetchOrigin || !readerContext.canPrefetchPdfImages()) return; + clearTimeout(readerContext.pdfManifestPrefetchTimer); + readerContext.pdfManifestPrefetchTimer = setTimeout(() => { + readerContext.pdfManifestPrefetchTimer = 0; + if (readerContext.readerAbortController.signal.aborted || document.hidden || !navigator.onLine || + readerContext.pdfManifestPrefetchActive.size >= readerContext.pdfManifestPrefetchConcurrency() || + readerContext.pdfManifestPrefetchCount >= readerContext.pdfManifestPrefetchMaxPages() || + (readerContext.pdfActiveRenders && readerContext.content.querySelector('.reader-page[data-render-visible="1"][data-render-state="rendering"]'))) return; + while (readerContext.pdfManifestPrefetchActive.size < readerContext.pdfManifestPrefetchConcurrency()) { + if (readerContext.pdfManifestPrefetchCount >= readerContext.pdfManifestPrefetchMaxPages()) return; + if (readerContext.pdfManifestPrefetchWrapped && readerContext.pdfManifestPrefetchNext >= readerContext.pdfManifestPrefetchOrigin) return; + if (readerContext.pdfManifestPrefetchNext > readerContext.documentState.pageCount) { + readerContext.pdfManifestPrefetchNext = 1; + readerContext.pdfManifestPrefetchWrapped = true; + } + const next = readerContext.pdfManifestPrefetchNext++; + const shell = readerContext.pdfManifestShells[next]; + if (shell && ["rendering", "rendered"].includes(shell.dataset.renderState)) continue; + const url = readerContext.pdfPageManifest.pageUrl(next); + if (readerContext.pdfManifestPrefetches.has(url)) continue; + const image = new Image(); + const record = { image, url, timeout: 0 }; + image.decoding = "async"; + image.fetchPriority = "low"; + const finish = (failed) => { + if (!readerContext.pdfManifestPrefetchActive.has(record)) return; + readerContext.pdfManifestPrefetchActive.delete(record); + clearTimeout(record.timeout); + image.onload = image.onerror = null; + if (failed) { + readerContext.pdfManifestPrefetches.delete(url); + image.removeAttribute("src"); + } + readerContext.trimPdfManifestPrefetches(); + readerContext.queuePdfManifestPrefetch(); + }; + image.onload = () => finish(false); + image.onerror = () => finish(true); + readerContext.pdfManifestPrefetches.set(url, record); + readerContext.pdfManifestPrefetchActive.add(record); + readerContext.pdfManifestPrefetchCount++; + record.timeout = setTimeout(() => finish(true), 15000); + image.src = url; + readerContext.trimPdfManifestPrefetches(); + } + }, delay); + } + + function prioritizePdfManifestPrefetch(target) { + for (const record of [...readerContext.pdfManifestPrefetchActive]) { + if (record.url !== target) readerContext.cancelPdfManifestPrefetch(record); + } + const record = readerContext.pdfManifestPrefetches.get(target); + if (record && readerContext.pdfManifestPrefetchActive.has(record)) record.image.fetchPriority = "high"; + } + + function schedulePdfManifestPrefetch(page) { + if (readerContext.pdfHybridUrl) return; + if (!["pdf-pages", "image-pages"].includes(readerContext.capability.mode) || !readerContext.pdfPageManifest) return; + const origin = Math.max(1, Math.min(readerContext.documentState.pageCount + 1, page + 1)); + if (readerContext.pdfManifestPrefetchOrigin === origin && readerContext.pdfManifestPrefetchNext) { + if (!readerContext.pdfManifestPrefetchActive.size && !readerContext.pdfManifestPrefetchTimer) readerContext.queuePdfManifestPrefetch(); + return; + } + readerContext.stopPdfManifestPrefetch(); + readerContext.pdfManifestPrefetchOrigin = readerContext.pdfManifestPrefetchNext = origin; + readerContext.pdfManifestPrefetchWrapped = false; + readerContext.queuePdfManifestPrefetch(); + } + + function trimPdfManifestImages(protectedShell) { + readerContext.trimPdfSurfaces(protectedShell); + } + + function cancelSpeculativePdfRenders(protectedShell) { + for (const shell of readerContext.content.querySelectorAll('.reader-page')) { + if (!shell._hybridRenderPromise && shell.dataset.renderState !== "rendering") continue; + if (!shell._pdfRenderOwnsSlot && !shell._renderStarted) continue; + if ( + shell === protectedShell || + readerContext.isPdfPageVisible(shell) || readerContext.pdfSelectionIntersects(shell) + ) + continue; + shell._pdfRenderCancelled = true; + shell._renderCancel?.(); + shell._hybridCancel?.(); + } + } + + function promotePdfRenderWaiter(shell) { + for (const waiter of readerContext.pdfRenderWaiters) { + if (waiter.shell === shell) waiter.priority = Math.max(waiter.priority, readerContext.pdfPagePriority(shell)); + } + } + + function acquirePdfRenderSlot(priority = false, shell = null, signal = null) { + if (readerContext.readerAbortController.signal.aborted) return Promise.reject(readerContext.readerAbortError()); + if (signal?.aborted) return Promise.reject(readerContext.readerAbortError()); + const limit = matchMedia("(max-width: 700px)").matches ? 1 : 2; + if (readerContext.pdfActiveRenders < limit) { + readerContext.pdfActiveRenders++; + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + let settled = false; + const remove = () => { + const index = readerContext.pdfRenderWaiters.indexOf(waiter); + if (index >= 0) readerContext.pdfRenderWaiters.splice(index, 1); + signal?.removeEventListener("abort", abort); + }; + const abort = () => { + if (settled) return; + settled = true; + remove(); + reject(readerContext.readerAbortError()); + }; + priority = readerContext.normalizePdfPriority(priority); + const waiter = { + shell, + priority, + resolve: () => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", abort); + readerContext.pdfActiveRenders++; + resolve(); + }, + reject: (error) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", abort); + reject(error); + } + }; + signal?.addEventListener("abort", abort, { once: true }); + const index = readerContext.pdfRenderWaiters.findIndex((item) => item.priority < priority); + if (index < 0) readerContext.pdfRenderWaiters.push(waiter); + else readerContext.pdfRenderWaiters.splice(index, 0, waiter); + }); + } + + function releasePdfRenderSlot() { + readerContext.pdfActiveRenders = Math.max(0, readerContext.pdfActiveRenders - 1); + if (readerContext.readerAbortController.signal.aborted) return; + let index = -1, best = -1, nearest = Infinity; + for (let i = 0; i < readerContext.pdfRenderWaiters.length; i++) { + const waiter = readerContext.pdfRenderWaiters[i], value = waiter.shell ? readerContext.pdfPagePriority(waiter.shell) : waiter.priority; + const rect = waiter.shell?.getBoundingClientRect(), bounds = readerContext.viewport.getBoundingClientRect(); + const distance = rect ? Math.max(bounds.top - rect.bottom, rect.top - bounds.bottom, 0) : Infinity; + if (value > best || (value === best && distance < nearest)) { + best = value; nearest = distance; index = i; + } + } + if (index < 0) index = 0; + const waiter = readerContext.pdfRenderWaiters.splice(index, 1)[0]; + if (waiter) waiter.resolve(); + } + + function acquirePdfTextSlot(priority = false, request = null) { + if (readerContext.readerAbortController.signal.aborted) return Promise.reject(readerContext.readerAbortError()); + return new Promise((resolve, reject) => { + readerContext.pdfTextWaiters.push({ priority, request, resolve, reject }); + readerContext.drainPdfTextWaiters(); + }); + } + + function drainPdfTextWaiters() { + if (readerContext.readerAbortController.signal.aborted) return; + const limit = matchMedia("(max-width: 700px)").matches ? 2 : 3; + for (let index = readerContext.pdfTextWaiters.length - 1; index >= 0; index--) { + const request = readerContext.pdfTextWaiters[index].request; + if (request && !request.demanded && !readerContext.isReaderGenerationCurrent("search", request.generation)) { + if (readerContext.pdfTextContentPromises.get(request.page)?.request === request) + readerContext.pdfTextContentPromises.delete(request.page); + readerContext.pdfTextWaiters.splice(index, 1)[0].reject(readerContext.readerAbortError()); + } + } + while (readerContext.pdfActiveTextLoads < limit) { + let index = readerContext.pdfTextWaiters.findIndex(waiter => waiter.priority || waiter.request?.demanded); + if (index < 0) index = readerContext.pdfTextWaiters.findIndex(waiter => + !waiter.request || readerContext.pdfActiveSearchTextLoads < limit - 1); + if (index < 0) return; + const waiter = readerContext.pdfTextWaiters.splice(index, 1)[0]; + const background = !!waiter.request && !waiter.request.demanded; + readerContext.pdfActiveTextLoads++; + if (background) readerContext.pdfActiveSearchTextLoads++; + waiter.resolve(background); + } + } + + function releasePdfTextSlot(background = false) { + readerContext.pdfActiveTextLoads = Math.max(0, readerContext.pdfActiveTextLoads - 1); + if (background) readerContext.pdfActiveSearchTextLoads = Math.max(0, readerContext.pdfActiveSearchTextLoads - 1); + readerContext.drainPdfTextWaiters(); + } + + function trimPdfCanvases(protectedShell) { + readerContext.trimPdfSurfaces(protectedShell); + } + + function rerenderVisiblePdfPages() { + readerContext.nextReaderGeneration("pdf"); + for (const shell of readerContext.content.querySelectorAll(".reader-page")) { + const rect = shell.getBoundingClientRect(); + if (rect.bottom >= -1200 && rect.top <= innerHeight + 1200) { + if (shell._renderPromise) shell.dataset.pendingRerender = "1"; + else readerContext.renderPdfInBackground(shell, true); + } + } + } + + function refreshNearbyPdfPages() { + if (!readerContext.documentState.restorationReady || !["pdf", "pdf-pages", "image-pages"].includes(readerContext.capability.mode)) return; + const page = readerContext.documentState.page; + for (const offset of [0, 1, 2, 3, -1]) { + const candidate = page + offset * readerContext.pdfScrollDirection; + const shell = readerContext.content.querySelector(`.reader-page[data-page="${candidate}"]`); + if (shell && shell.dataset.renderState !== "rendered") readerContext.renderPdfInBackground(shell); + } + } + + return { + pdfManifestPrefetchConcurrency, + pdfManifestPrefetchMaxPages, + canPrefetchPdfImages, + cancelPdfManifestPrefetch, + stopPdfManifestPrefetch, + trimPdfManifestPrefetches, + pdfPixelBudget, + pdfSurfacePixels, + pdfRetainedPixels, + trimPdfSurfaces, + clearPdfManifestPrefetches, + takePdfManifestPrefetch, + queuePdfManifestPrefetch, + prioritizePdfManifestPrefetch, + schedulePdfManifestPrefetch, + trimPdfManifestImages, + cancelSpeculativePdfRenders, + promotePdfRenderWaiter, + acquirePdfRenderSlot, + releasePdfRenderSlot, + acquirePdfTextSlot, + drainPdfTextWaiters, + releasePdfTextSlot, + trimPdfCanvases, + rerenderVisiblePdfPages, + refreshNearbyPdfPages + }; +} diff --git a/static/reader/pdf/reader-pdf-search.mjs b/static/reader/pdf/reader-pdf-search.mjs new file mode 100644 index 0000000000000000000000000000000000000000..7f13e35b37421b94e760eb36300ea6c94bdecc43 --- /dev/null +++ b/static/reader/pdf/reader-pdf-search.mjs @@ -0,0 +1,293 @@ +export function createReaderPdfSearch(readerContext) { + async function ensurePdfBookSearchClient(manifest) { + let textUrl = ""; + let partitioned = null; + if (manifest?.text_layer) { + readerContext.pdfTextLayerRepository ||= readerContext.createPdfV3Repository(manifest.source_sha256, manifest.page_count); + const index = await readerContext.pdfTextLayerRepository.textIndex(manifest.text_layer); + manifest = { ...manifest, book_text: index.book_text }; + textUrl = readerContext.v3ResourceUrl(index.book_text); + if (index.search_partitions) partitioned = {generation: index.generation, + partitions: index.search_partitions.map(part => ({start: part.start, end: part.end, + url: readerContext.v3ResourceUrl(part.resource), bytes: part.resource.bytes, sha256: part.resource.sha256}))}; + } + if (!manifest?.book_text?.path) throw new Error("本书集中全文尚未生成"); + if (!readerContext.pdfBookSearchModulePromise) + readerContext.pdfBookSearchModulePromise = import("/static/reader/search/reader-pdf-book-search.mjs").catch(error => { + readerContext.pdfBookSearchModulePromise = null; + throw error; + }); + const module = await readerContext.pdfBookSearchModulePromise; + readerContext.assertReaderActive(); + if (readerContext.pdfBookSearchClient?.failed) { + readerContext.pdfBookSearchClient.dispose(); + readerContext.pdfBookSearchClient = null; + } + if (!readerContext.pdfBookSearchClient) + readerContext.pdfBookSearchClient = module.createPdfBookSearch({ + url: textUrl || readerContext.pdfOcrSource().assetUrl(manifest.book_text.path), + sha256: manifest.book_text.sha256, + bytes: manifest.book_text.bytes, + pageCount: readerContext.documentState.pageCount, + sourceSha: manifest.source_sha256, + ...partitioned + }); + return readerContext.pdfBookSearchClient; + } + + async function prefetchPdfBookSearch() { + if (readerContext.readerAbortController.signal.aborted || readerContext.pdfBookSearchPrefetchPromise || !readerContext.ocrManifestUrl || + !["pdf", "pdf-pages"].includes(readerContext.capability.mode) || navigator.connection?.saveData) return; + const connection = navigator.connection; + if (["slow-2g", "2g"].includes(connection?.effectiveType)) return; + readerContext.pdfBookSearchPrefetchPromise = (async () => { + const manifest = await readerContext.loadPdfOcrManifest(); + const bytes = Number(manifest?.book_text?.bytes); + if (!Number.isSafeInteger(bytes) || bytes < 1 || bytes > readerContext.PDF_BOOK_SEARCH_PREFETCH_MAX_BYTES) return; + const client = await readerContext.ensurePdfBookSearchClient(manifest); + await client.prefetch(); + })().catch(() => {}); + } + + function schedulePdfBookSearchPrefetch() { + if (readerContext.pdfBookSearchPrefetchTimer || readerContext.pdfBookSearchPrefetchPromise || !readerContext.ocrManifestUrl || + !["pdf", "pdf-pages"].includes(readerContext.capability.mode) || navigator.connection?.saveData) return; + readerContext.pdfBookSearchPrefetchTimer = setTimeout(() => { + readerContext.pdfBookSearchPrefetchTimer = 0; + const idle = window.requestIdleCallback || ((callback) => setTimeout(callback, 0)); + idle(() => readerContext.prefetchPdfBookSearch(), { timeout: 1500 }); + }, readerContext.PDF_BOOK_SEARCH_PREFETCH_DELAY); + } + + async function searchConcentratedPdf(query, generation) { + const manifest = await readerContext.loadPdfOcrManifest(); + const client = await readerContext.ensurePdfBookSearchClient(manifest); + let firstPageResults = []; + const attach = (page) => { + const results = page.results.map((hit, position) => { + const previous = page.offset === 0 ? firstPageResults[position] : null; + if (previous?.page === hit.page && previous.start === hit.start && previous.length === hit.length) + return previous; + return { ...hit, location: `第 ${hit.page} 页`, activate: async nav => { + if (!await readerContext.goToPage(hit.page, nav) || !readerContext.isReaderGenerationCurrent("navigation", nav)) + return false; + readerContext.content.querySelectorAll(".reader-text-hit-box").forEach(node => node.remove()); + const shell = readerContext.content.querySelector(`.reader-page[data-page="${hit.page}"]`); + if (shell && (hit.boxes.length || hit.textGeneration)) { + let boxes = hit.boxes; + if (hit.textGeneration) { + const layer = await readerContext.pdfTextLayerRepository.textPage(manifest.text_layer, hit.page); + if (!readerContext.isReaderGenerationCurrent("navigation", nav)) return false; + if (layer.generation !== hit.textGeneration) throw new Error("PDF_V3_GENERATION_CHANGED"); + boxes = readerContext.hitBoxes({text: layer.text, text_spans: layer.regions.map(region => ({ + start: region.start, end: region.end, box: region.box, precision: region.mapping_precision + }))}, hit.start, hit.length); + } + if (readerContext.pdfV3Manifest) { + await readerContext.renderPdfOcrText(shell); + if (!readerContext.isReaderGenerationCurrent("navigation", nav)) return false; + } + readerContext.paintTextHit(shell, boxes, hit); + shell.querySelector(".reader-text-hit-box")?.scrollIntoView({ block: "center" }); + } + return true; + } }; + }); + if (page.offset === 0) firstPageResults = results; + return { ...page, results }; + }; + readerContext.fullSearchSession.pageLoader = async offset => attach(await client.page(offset)); + const page = await client.search(query, partial => { + if (!readerContext.isReaderGenerationCurrent("search", generation)) return; + readerContext.fullSearchSession.page = attach(partial); + readerContext.updateSearchState({ results: readerContext.fullSearchSession.page.results, index: -1 }); + readerContext.renderFullSearchResults(); + readerContext.fullSearchStatus.textContent = partial.total + ? `${partial.total} 个结果(正在搜索… ${partial.scanned} / ${partial.pages} 页)` + : `正在搜索正文… ${partial.scanned} / ${partial.pages} 页`; + }); + if (!readerContext.isReaderGenerationCurrent("search", generation)) return []; + readerContext.fullSearchSession.page = attach(page); + return readerContext.fullSearchSession.page.results; + } + + function makePdfSearchPageLoader(groups, total, pattern, firstPage) { + const pdf = readerContext.pdfDocument; + return async (requestedOffset, generation = readerContext.readerRuntime.currentGeneration("search")) => { + const pageSize = 50, results = []; + const offset = readerContext.fullSearchPageOffset(total, requestedOffset, pageSize); + if (!offset) return { total, offset, pageSize, results: firstPage.slice() }; + let skipped = 0; + for (const group of groups) { + if (skipped + group.count <= offset) { + skipped += group.count; + continue; + } + const text = await readerContext.loadPdfSearchText(pdf, group.page, generation); + let examined = 0; + for (const match of text.matchAll(pattern)) { + if (++examined % 2048 === 0) { + await readerContext.waitForReader(); + if (!readerContext.isReaderGenerationCurrent("search", generation)) + throw new DOMException("Search cancelled", "AbortError"); + } + if (skipped++ < offset) continue; + results.push({ + location: `第 ${group.page} 页`, + snippet: readerContext.fullSearchSnippet(text, match.index, match[0].length), + activate: (navigationGeneration) => readerContext.goToPage(group.page, navigationGeneration) + }); + if (results.length === pageSize) break; + } + if (results.length === pageSize) break; + } + return { total, offset, pageSize, results }; + }; + } + + function publishPdfSearchProgress(generation, groups, total, pattern, firstPage, scanned, pages) { + if (!readerContext.isReaderGenerationCurrent("search", generation)) return false; + readerContext.fullSearchSession.pageLoader = readerContext.makePdfSearchPageLoader(groups, total, pattern, firstPage); + readerContext.fullSearchSession.page = { total, offset: 0, pageSize: 50, results: firstPage.slice() }; + readerContext.updateSearchState({ results: readerContext.fullSearchSession.page.results, index: -1 }); + readerContext.renderFullSearchResults(); + readerContext.fullSearchStatus.textContent = total + ? `${total} 个结果(正在搜索… ${scanned} / ${pages} 页)` + : `正在搜索正文… ${scanned} / ${pages} 页,暂未找到匹配`; + return true; + } + + async function getPdfPersistentTextStore(pdf) { + const version = readerContext.pdfFetchPolicy?.identity; + if (!version || readerContext.ocrManifestUrl || readerContext.readerAbortController.signal.aborted) return null; + if (readerContext.pdfPersistentTextVersion !== version) { + readerContext.pdfPersistentTextStore?.dispose(); + readerContext.pdfPersistentTextStore = null; + readerContext.pdfPersistentTextVersion = version; + const pending = (async () => { + let timer; + const module = await readerContext.awaitReader(Promise.race([import("/static/reader/pdf/reader-pdf-text-store.mjs"), + new Promise(resolve => {timer=setTimeout(()=>resolve(null),1000);})])).finally(()=>clearTimeout(timer)); + if (!module) return null; + const key = await module.pdfTextStoreKey({source:readerContext.sourceUrl,version,pageCount:pdf.numPages, + engine:VoiceOfMLReaderResources.vendors.pdf.sha256}); + if (!key || readerContext.readerAbortController.signal.aborted || readerContext.pdfFetchPolicy?.identity !== version || readerContext.pdfPersistentTextOpening !== pending) return null; + return readerContext.pdfPersistentTextStore = module.createPdfTextStore(key,{signal:readerContext.readerAbortController.signal}); + })().catch(() => null); + readerContext.pdfPersistentTextOpening = pending; + } + return readerContext.pdfPersistentTextStore || readerContext.pdfPersistentTextOpening; + } + + function cachePdfSearchText(page, text) { + const bytes = text.length * 2; + if (bytes > readerContext.PDF_SEARCH_TEXT_CACHE_BYTES) return; + const previous = readerContext.pdfSearchTextCache.get(page); + if (previous !== undefined) { + readerContext.pdfSearchTextCacheBytes -= previous.length * 2; + readerContext.pdfSearchTextCache.delete(page); + } + readerContext.pdfSearchTextCache.set(page, text); + readerContext.pdfSearchTextCacheBytes += bytes; + while (readerContext.pdfSearchTextCacheBytes > readerContext.PDF_SEARCH_TEXT_CACHE_BYTES) { + const oldest = readerContext.pdfSearchTextCache.keys().next().value; + readerContext.pdfSearchTextCacheBytes -= readerContext.pdfSearchTextCache.get(oldest).length * 2; + readerContext.pdfSearchTextCache.delete(oldest); + } + } + + async function loadPdfSearchText(pdf, page, generation) { + if (!readerContext.isReaderGenerationCurrent("search", generation)) + throw new DOMException("Search cancelled", "AbortError"); + let text = readerContext.pdfSearchTextCache.get(page); + if (text !== undefined) { + readerContext.pdfSearchTextCache.delete(page); + readerContext.pdfSearchTextCache.set(page, text); + return text; + } + const store = await readerContext.getPdfPersistentTextStore(pdf); + const version = readerContext.pdfFetchPolicy?.identity; + const stored = store ? await store.get(page).catch(() => null) : null; + if (!readerContext.isReaderGenerationCurrent("search", generation)) throw readerContext.readerAbortError(); + if (stored !== null && version && version === readerContext.pdfFetchPolicy?.identity) { + readerContext.cachePdfSearchText(page, stored); + return stored; + } + const pdfPage = await readerContext.awaitReader(pdf.getPage(page)); + if (!readerContext.isReaderGenerationCurrent("search", generation)) + throw new DOMException("Search cancelled", "AbortError"); + const textContent = await readerContext.loadPdfTextContent(page, pdfPage, generation); + if (!readerContext.isReaderGenerationCurrent("search", generation)) + throw new DOMException("Search cancelled", "AbortError"); + text = readerContext.normalizePdfSearchText(readerContext.pdfTextContent(textContent.items)); + readerContext.cachePdfSearchText(page, text); + if (store && version && version === readerContext.pdfFetchPolicy?.identity) store.put(page,text).catch(() => {}); + return text; + } + + async function fullSearchPdfMatches(query, generation) { + const pdf = readerContext.pdfDocument, + groups = [], firstPage = [], + pattern = new RegExp(readerContext.fullSearchEscape(readerContext.normalizePdfSearchText(query)), "giu"); + if (!pdf) return []; + const pending = new Map(); + let nextPage = 1; + const fill = () => { + while (pending.size < 2 && nextPage <= pdf.numPages && readerContext.isReaderGenerationCurrent("search", generation)) { + const page = nextPage++; + pending.set(page, readerContext.loadPdfSearchText(pdf, page, generation).then( + text => ({ text }), error => ({ error }))); + } + }; + fill(); + let total = 0, hasText = false; + for (let page = 1; page <= pdf.numPages; page++) { + if (!readerContext.isReaderGenerationCurrent("search", generation)) return []; + const result = await pending.get(page); + pending.delete(page); + if (!readerContext.isReaderGenerationCurrent("search", generation)) return []; + if (result.error) throw result.error; + const text = result.text; + fill(); + hasText ||= !!text.trim(); + let count = 0; + for (const match of text.matchAll(pattern)) { + count++; + if (firstPage.length < 50) firstPage.push({ + location: `第 ${page} 页`, + snippet: readerContext.fullSearchSnippet(text, match.index, match[0].length), + activate: (navigationGeneration) => readerContext.goToPage(page, navigationGeneration) + }); + if (count % 2048 === 0) { + await readerContext.waitForReader(); + if (!readerContext.isReaderGenerationCurrent("search", generation)) return []; + } + } + if (count) { + groups.push({ page, count }); + total += count; + readerContext.highlightPdfText(readerContext.content.querySelector(`.reader-page[data-page="${page}"]`), query); + } + if (page === 1 || (count && total <= 50) || page % 8 === 0 || page === pdf.numPages) { + if (!readerContext.publishPdfSearchProgress(generation, groups, total, pattern, firstPage, page, pdf.numPages)) return []; + await readerContext.waitForReader(0, true); + } + } + if (!hasText) throw new Error("此 PDF 尚无可搜索文字层,需要完成 OCR 后才能搜索正文"); + readerContext.publishPdfSearchProgress(generation, groups, total, pattern, firstPage, pdf.numPages, pdf.numPages); + return readerContext.fullSearchSession.page.results; + } + + return { + ensurePdfBookSearchClient, + prefetchPdfBookSearch, + schedulePdfBookSearchPrefetch, + searchConcentratedPdf, + makePdfSearchPageLoader, + publishPdfSearchProgress, + getPdfPersistentTextStore, + cachePdfSearchText, + loadPdfSearchText, + fullSearchPdfMatches + }; +} diff --git a/static/reader/pdf/reader-pdf-text-runtime.mjs b/static/reader/pdf/reader-pdf-text-runtime.mjs new file mode 100644 index 0000000000000000000000000000000000000000..7b3fd43d1eed6a2764e30bf3137c9ad704106c1a --- /dev/null +++ b/static/reader/pdf/reader-pdf-text-runtime.mjs @@ -0,0 +1,312 @@ +export function createReaderPdfTextRuntime(readerContext) { + function pdfOcrSource() { + return VoiceOfMLReader.pdfPageSource(readerContext.ocrManifestUrl, location.href, location.origin, "ocr-manifest.json"); + } + + function v3ResourceUrl(ref) { + readerContext.validateResource(ref); + return new URL(readerContext.readerDownloadApiUrl(`/api/reader-bucket-resource?path=${encodeURIComponent(ref.path)}`), location.href).href; + } + + function createPdfV3Repository(sourceSha, pageCount) { + return readerContext.createV3Repository({ sourceSha, pageCount, signal: readerContext.readerAbortController.signal, + resourceUrl: readerContext.v3ResourceUrl, + readJson: (url, ref) => readerContext.readPdfOcrJson(url, VoiceOfMLReaderSecurity.LIMITS.chapterBytes, ref.sha256, ref.bytes), + readBytes: async (url, ref) => { + const response = await readerContext.fetchReaderUrl(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const packed = await VoiceOfMLReaderSecurity.readBytes(response, VoiceOfMLReaderSecurity.LIMITS.chapterBytes); + const actual = [...new Uint8Array(await crypto.subtle.digest("SHA-256", packed))] + .map(value => value.toString(16).padStart(2, "0")).join(""); + if (packed.byteLength !== ref.bytes || actual !== ref.sha256) throw new Error("PDF_V3_HASH_MISMATCH"); + return packed; + } + }); + } + + async function readPdfOcrJson(url, limit = VoiceOfMLReaderSecurity.LIMITS.chapterBytes, digest = "", size = 0, priority = "auto") { + const response = await readerContext.fetchReaderUrl(url, { priority }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + let bytes = await VoiceOfMLReaderSecurity.readBytes(response, limit); + if (size && bytes.byteLength !== size) throw new Error("PDF_OCR_SIZE_MISMATCH"); + if (digest) { + const actual = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))] + .map((value) => value.toString(16).padStart(2, "0")).join(""); + if (actual !== digest) throw new Error("PDF_OCR_HASH_MISMATCH"); + } + const parsed = new URL(url, location.href); + if (/\.gz$/i.test(parsed.searchParams.get("path") || parsed.pathname)) { + if (typeof DecompressionStream !== "function") throw new Error("PDF_OCR_GZIP_UNSUPPORTED"); + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + bytes = await VoiceOfMLReaderSecurity.readBytes(new Response(stream), limit); + } + return JSON.parse(new TextDecoder().decode(bytes)); + } + + async function loadPdfOcrManifest() { + if (readerContext.pdfOcrManifest) return readerContext.pdfOcrManifest; + if (!readerContext.ocrManifestUrl) return null; + if (!readerContext.pdfOcrManifestPromise) { + if (!readerContext.pdfOcrSource()) throw new Error("PDF_OCR_SOURCE_INVALID"); + readerContext.pdfOcrManifestPromise = readerContext.readPdfOcrJson(readerContext.ocrManifestUrl, VoiceOfMLReaderSecurity.LIMITS.manifestBytes, "", 0, "high").then((manifest) => { + readerContext.assertReaderActive(); + if (manifest.page_count !== readerContext.documentState.pageCount) throw new Error("PDF_OCR_PAGE_COUNT_MISMATCH"); + readerContext.pdfOcrManifest = VoiceOfMLReaderSecurity.validatePdfOcrManifest(manifest); + return readerContext.pdfOcrManifest; + }); + } + try { return await readerContext.pdfOcrManifestPromise; } + catch (error) { readerContext.pdfOcrManifestPromise = null; throw error; } + } + + function loadPdfOcrPage(page, priority = "high") { + const cached = readerContext.cachedPdfOcrPage(page); + if (cached) return Promise.resolve(cached); + const pending = readerContext.pdfOcrPagePromises.get(page); + if (pending) return pending; + const task = Promise.resolve().then(() => { + // OCR is published as its own manifest. The image page manifest may be + // intentionally lean and contain only page_count. + return readerContext.loadPdfOcrManifest().then((manifest) => { + if (manifest?.text_layer) { + readerContext.pdfTextLayerRepository ||= readerContext.createPdfV3Repository(manifest.source_sha256, manifest.page_count); + return readerContext.pdfTextLayerRepository.textPage(manifest.text_layer, page).then(payload => { + if (readerContext.pdfV3Map) { + const geometry = readerContext.pdfV3Map.pages[page - 1]; + if (Math.abs((payload.geometry.width / payload.geometry.height) / (geometry.width / geometry.height) - 1) > .01) + throw new Error("PDF_V3_TEXT_GEOMETRY_MISMATCH"); + } + readerContext.assertReaderActive(); readerContext.cachePdfOcrPage(page, payload); return payload; + }); + } + const entry = manifest?.pages?.[page - 1]; + if (!entry || entry.p !== page) throw new Error("PDF_OCR_PAGE_MISSING"); + return readerContext.readPdfOcrJson(readerContext.pdfOcrSource().assetUrl(entry.o), + VoiceOfMLReaderSecurity.LIMITS.chapterBytes, entry.os, entry.ob, priority).then((payload) => { + VoiceOfMLReaderSecurity.validatePdfOcrPage(payload, page); + readerContext.cachePdfOcrPage(page, payload); + return payload; + }); + }); + }).finally(() => readerContext.pdfOcrPagePromises.delete(page)); + readerContext.pdfOcrPagePromises.set(page, task); + return task; + } + + function estimatePdfTextContentBytes(content) { + let bytes = 0; + for (const item of content?.items || []) + bytes += String(item.str || "").length * 2 + (item.transform?.length || 0) * 8 + 64; + for (const style of Object.values(content?.styles || {})) + bytes += Object.entries(style || {}).reduce( + (total, [key, value]) => total + key.length + String(value).length * 2 + 16, 0 + ); + return Math.max(128, bytes); + } + + function cachePdfTextContent(page, content) { + const bytes = readerContext.estimatePdfTextContentBytes(content); + if (bytes > readerContext.PDF_TEXT_CONTENT_CACHE_BYTES) return; + const previous = readerContext.pdfTextContentCache.get(page); + if (previous) readerContext.pdfTextContentCacheBytes -= previous.bytes; + readerContext.pdfTextContentCache.delete(page); + readerContext.pdfTextContentCache.set(page, { content, bytes }); + readerContext.pdfTextContentCacheBytes += bytes; + while (readerContext.pdfTextContentCacheBytes > readerContext.PDF_TEXT_CONTENT_CACHE_BYTES) { + const oldest = readerContext.pdfTextContentCache.keys().next().value; + const removed = readerContext.pdfTextContentCache.get(oldest); + readerContext.pdfTextContentCacheBytes -= removed.bytes; + readerContext.pdfTextContentCache.delete(oldest); + } + } + + function cachedPdfTextContent(page) { + const record = readerContext.pdfTextContentCache.get(page); + if (!record) return null; + readerContext.pdfTextContentCache.delete(page); + readerContext.pdfTextContentCache.set(page, record); + return record.content; + } + + function loadPdfTextContent(page, pdfPage, generation = null) { + readerContext.assertReaderActive(); + const cached = readerContext.cachedPdfTextContent(page); + if (cached) return Promise.resolve(cached); + const pending = readerContext.pdfTextContentPromises.get(page); + if (pending) { + if (generation === null) pending.request.demanded = true; + else pending.request.generation = generation; + readerContext.drainPdfTextWaiters(); + return pending; + } + // Rendering and search share extraction, but retain their own cancellation + // and result ownership. Only active work lives here, not another text cache. + const request = { demanded: generation === null, generation, page }; + const task = Promise.resolve().then(async () => { + const background = await readerContext.acquirePdfTextSlot(request.demanded, request); + try { + readerContext.assertReaderActive(); + if (!request.demanded && !readerContext.isReaderGenerationCurrent("search", request.generation)) throw readerContext.readerAbortError(); + const text = await readerContext.awaitReader(pdfPage.getTextContent()); + readerContext.cachePdfTextContent(page, text); + return text; + } finally { + readerContext.releasePdfTextSlot(background); + } + }) + .finally(() => { + if (readerContext.pdfTextContentPromises.get(page) === task) readerContext.pdfTextContentPromises.delete(page); + }); + task.request = request; + readerContext.pdfTextContentPromises.set(page, task); + return task; + } + + function estimatePdfOcrPageBytes(payload) { + if (payload?.kind === "pdf-text-layer") return Math.max(128, JSON.stringify(payload).length * 2); + return Math.max(128, (payload?.blocks || []).reduce( + (total, block) => total + String(block?.t || "").length * 2 + 48, 0 + )); + } + + function cachePdfOcrPage(page, payload) { + const bytes = readerContext.estimatePdfOcrPageBytes(payload); + if (bytes > readerContext.PDF_OCR_PAGE_CACHE_BYTES) return; + const previous = readerContext.pdfOcrPageCache.get(page); + if (previous) readerContext.pdfOcrPageCacheBytes -= previous.bytes; + readerContext.pdfOcrPageCache.delete(page); + readerContext.pdfOcrPageCache.set(page, { payload, bytes }); + readerContext.pdfOcrPageCacheBytes += bytes; + while (readerContext.pdfOcrPageCacheBytes > readerContext.PDF_OCR_PAGE_CACHE_BYTES) { + const oldest = readerContext.pdfOcrPageCache.keys().next().value; + const removed = readerContext.pdfOcrPageCache.get(oldest); + readerContext.pdfOcrPageCacheBytes -= removed.bytes; + readerContext.pdfOcrPageCache.delete(oldest); + } + } + + function cachedPdfOcrPage(page) { + const record = readerContext.pdfOcrPageCache.get(page); + if (!record) return null; + readerContext.pdfOcrPageCache.delete(page); + readerContext.pdfOcrPageCache.set(page, record); + return record.payload; + } + + async function renderPdfOcrText(shell) { + if (shell.dataset.textReady === "1" || !readerContext.ocrManifestUrl) return; + if (shell._ocrPromise) return shell._ocrPromise; + const layer = shell.querySelector(".reader-pdf-text"); + if (!layer) return; + const epoch = shell._textEpoch || 0; + const task = (async () => { + try { + const page = Number(shell.dataset.page); + await readerContext.acquirePdfTextSlot(readerContext.isPdfPageVisible(shell)); + try { + if (epoch !== (shell._textEpoch || 0)) return; + const payload = await readerContext.loadPdfOcrPage(page, readerContext.isPdfPageVisible(shell) ? "high" : "low"); + readerContext.assertReaderActive(); + if (epoch !== (shell._textEpoch || 0)) return; + if (payload.kind === "pdf-text-layer") { + readerContext.populateIndependentTextLayer(layer, payload); + shell._bookmarkTextItems = payload.regions.map(region => ({ text: region.text, y: region.box[1] })); + } else { + VoiceOfMLReaderSecurity.validatePdfOcrPage(payload, page); + readerContext.populateOcrTextLayer(layer, payload.blocks, payload.layout); + shell._bookmarkTextItems = payload.blocks.map((block) => ({ text: block.t, y: block.b?.[1] || 0 })); + } + shell.dataset.textReady = "1"; + readerContext.highlightPdfText(shell); + } finally { + readerContext.releasePdfTextSlot(); + } + } catch (error) { + if (!readerContext.readerAbortController.signal.aborted) + console.warn(`PDF page ${shell.dataset.page} OCR text failed`, error); + } + })().finally(() => { if (shell._ocrPromise === task) delete shell._ocrPromise; }); + shell._ocrPromise = task; + return task; + } + + async function renderPdfText(page, shell, signal = null) { + if (readerContext.ocrManifestUrl && readerContext.pdfV3Manifest) return readerContext.renderPdfOcrText(shell); + if (shell.dataset.textReady === "1") return; + const layer = shell.querySelector(".reader-pdf-text"); + if (!layer || typeof page.getTextContent !== "function") return; + const epoch = shell._textEpoch || 0; + try { + const pageNumber = Number(shell.dataset.page); + const text = await readerContext.loadPdfTextContent(pageNumber, page); + readerContext.assertReaderActive(); + if (signal?.aborted) return; + readerContext.cachePdfTextContent(pageNumber, text); + if (epoch !== (shell._textEpoch || 0) || !shell.isConnected) return; + const pdfViewport = page.getViewport({ scale: 1 }); + shell._bookmarkTextItems = text.items + .filter((item) => item.str && item.str.trim()) + .map((item) => { + const point = + item.transform && pdfViewport.convertToViewportPoint + ? pdfViewport.convertToViewportPoint(item.transform[4], item.transform[5]) + : null; + return { + text: item.str, + y: point ? Math.max(0, Math.min(1, point[1] / Math.max(1, pdfViewport.height))) : 0 + }; + }); + readerContext.populatePdfTextLayer(layer, text.items, pdfViewport, text.styles); + shell.dataset.textReady = "1"; + readerContext.highlightPdfText(shell); + } catch (error) { + if (!readerContext.readerAbortController.signal.aborted && !signal?.aborted) + console.warn(`PDF page ${shell.dataset.page} text extraction failed`, error); + } + } + + function highlightPdfText(shell, query = readerContext.searchState.query) { + if (!shell || readerContext.readerAbortController.signal.aborted || !query) return; + const layer = shell.querySelector(".reader-pdf-text"); + if (!layer || layer.querySelector("mark")) return; + const walker = document.createTreeWalker(layer, NodeFilter.SHOW_TEXT), runs = []; + let raw = ""; + while (walker.nextNode()) { + const node = walker.currentNode, start = raw.length; + raw += node.data; + runs.push({ node, start, end: raw.length }); + } + const mapping = readerContext.pdfSearchText(raw); + const hits = []; + for (const hit of mapping.text.matchAll(new RegExp(readerContext.fullSearchEscape(readerContext.normalizePdfSearchText(query)), "giu"))) { + hits.push(hit); + if (hits.length === 100) break; + } + // Bound highlights in the displayed page, without limiting searchable results. + for (const hit of hits.reverse()) { + const start = readerContext.pdfSourceOffset(mapping, hit.index), end = readerContext.pdfSourceOffset(mapping, hit.index + hit[0].length - 1) + 1; + readerContext.highlightTextParts(runs.filter((run) => run.end > start && run.start < end).map((run) => ({ + node: run.node, start: Math.max(start, run.start) - run.start, end: Math.min(end, run.end) - run.start + }))); + } + } + + return { + pdfOcrSource, + v3ResourceUrl, + createPdfV3Repository, + readPdfOcrJson, + loadPdfOcrManifest, + loadPdfOcrPage, + estimatePdfTextContentBytes, + cachePdfTextContent, + cachedPdfTextContent, + loadPdfTextContent, + estimatePdfOcrPageBytes, + cachePdfOcrPage, + cachedPdfOcrPage, + renderPdfOcrText, + renderPdfText, + highlightPdfText + }; +} diff --git a/static/reader-pdf-text-store.mjs b/static/reader/pdf/reader-pdf-text-store.mjs similarity index 100% rename from static/reader-pdf-text-store.mjs rename to static/reader/pdf/reader-pdf-text-store.mjs diff --git a/static/reader-pdf-text.js b/static/reader/pdf/reader-pdf-text.js similarity index 100% rename from static/reader-pdf-text.js rename to static/reader/pdf/reader-pdf-text.js diff --git a/static/reader-v3.mjs b/static/reader/pdf/reader-v3.mjs similarity index 100% rename from static/reader-v3.mjs rename to static/reader/pdf/reader-v3.mjs diff --git a/static/reader/reader-components.mjs b/static/reader/reader-components.mjs new file mode 100644 index 0000000000000000000000000000000000000000..d0e371ed37d52bb4ae00fda14dffa3d67b8168e3 --- /dev/null +++ b/static/reader/reader-components.mjs @@ -0,0 +1,25 @@ +export { createReaderDiagnostics } from "./core/reader-diagnostics.mjs"; +export { createReaderLifecycle } from "./core/reader-lifecycle.mjs"; +export { createReaderSource } from "./core/reader-source.mjs"; +export { createReaderUi } from "./ui/reader-ui.mjs"; +export { createReaderToc } from "./ui/reader-toc.mjs"; +export { createReaderSearchBackends } from "./search/reader-search-backends.mjs"; +export { createReaderPdfScheduler } from "./pdf/reader-pdf-scheduler.mjs"; +export { createReaderNavigation } from "./ui/reader-navigation.mjs"; +export { createReaderFoliateFormat } from "./formats/reader-foliate-format.mjs"; +export { createReaderProgress } from "./ui/reader-progress.mjs"; +export { createReaderBookmarks } from "./ui/reader-bookmarks.mjs"; +export { createReaderMediaFormat } from "./formats/reader-media-format.mjs"; +export { createReaderDocumentLoader } from "./core/reader-document-loader.mjs"; +export { createReaderPdfPageWindow } from "./pdf/reader-pdf-page-window.mjs"; +export { createReaderPdfTextRuntime } from "./pdf/reader-pdf-text-runtime.mjs"; +export { createReaderHtmlFormat } from "./formats/reader-html-format.mjs"; +export { createReaderPdfRenderer } from "./pdf/reader-pdf-renderer.mjs"; +export { createReaderTextFormat } from "./formats/reader-text-format.mjs"; +export { createReaderChapterFormat } from "./formats/reader-chapter-format.mjs"; +export { createReaderDocxFormat } from "./formats/reader-docx-format.mjs"; +export { createReaderFormatController } from "./formats/reader-format-controller.mjs"; +export { createReaderPdfEngine } from "./pdf/reader-pdf-engine.mjs"; +export { createReaderPdfSearch } from "./pdf/reader-pdf-search.mjs"; +export { createReaderBindings } from "./core/reader-bindings.mjs"; +export { EPUB_HTML_TAGS, EPUB_HTML_VOID_TAGS, HTML_FRAME_STYLE, DISCOURSE_POST_SELECTOR, DISCOURSE_TITLE_SELECTOR, DISCOURSE_HTML_STYLE, SPREADSHEET_HTML_STYLE } from "./ui/reader-document-styles.mjs"; diff --git a/static/reader-book-text.js b/static/reader/search/reader-book-text.js similarity index 100% rename from static/reader-book-text.js rename to static/reader/search/reader-book-text.js diff --git a/static/reader-chapter-search.mjs b/static/reader/search/reader-chapter-search.mjs similarity index 97% rename from static/reader-chapter-search.mjs rename to static/reader/search/reader-chapter-search.mjs index 519d389f2c75b9f354ebb10fc9aa19e635c1dca1..da0f6761760e028c48c366cb41a34ab49098bebb 100644 --- a/static/reader-chapter-search.mjs +++ b/static/reader/search/reader-chapter-search.mjs @@ -1,5 +1,5 @@ export function createChapterSearch(configuration) { - const worker = new Worker("/static/reader-chapter-search-worker.mjs", { type: "module" }); + const worker = new Worker("/static/reader/search/workers/reader-chapter-search-worker.mjs", { type: "module" }); let sequence = 0, pending = null, disposed = false, session = null, failure = null; const cancelPending = () => { pending?.reject(new DOMException("Search cancelled", "AbortError")); diff --git a/static/reader-dom-search.mjs b/static/reader/search/reader-dom-search.mjs similarity index 100% rename from static/reader-dom-search.mjs rename to static/reader/search/reader-dom-search.mjs diff --git a/static/reader-pdf-book-search.mjs b/static/reader/search/reader-pdf-book-search.mjs similarity index 94% rename from static/reader-pdf-book-search.mjs rename to static/reader/search/reader-pdf-book-search.mjs index 5b4b4b4cd9e40fadbcb5029fbe2edb849556bab5..718e324296eff76adffc9205559718434f7e6134 100644 --- a/static/reader-pdf-book-search.mjs +++ b/static/reader/search/reader-pdf-book-search.mjs @@ -6,7 +6,7 @@ export function createPdfBookSearch(configuration) { pending = null; } function startWorker() { - const owner = worker = new Worker(new URL("./reader-pdf-book-search-worker.mjs", import.meta.url), { type: "module" }); + const owner = worker = new Worker(new URL("./workers/reader-pdf-book-search-worker.mjs", import.meta.url), { type: "module" }); owner.onmessage = ({ data }) => { if (worker !== owner || disposed) return; if (data.bookReady) { bookReady = true; return; } diff --git a/static/reader/search/reader-search-backends.mjs b/static/reader/search/reader-search-backends.mjs new file mode 100644 index 0000000000000000000000000000000000000000..4c8418fffaf88b055fbd6f26ab1a4905f33e23fb --- /dev/null +++ b/static/reader/search/reader-search-backends.mjs @@ -0,0 +1,263 @@ +export function createReaderSearchBackends(readerContext) { + function updateSearchState(patch) { + return readerContext.readerRuntime.update("search", patch); + } + + function publishSearchResults(generation, results) { + return readerContext.isReaderGenerationCurrent("search", generation) && readerContext.updateSearchState({ results }); + } + + function clearSearchHighlights(view) { + for (const mark of view.querySelectorAll("mark.search-match")) mark.replaceWith(mark.textContent); + view.normalize(); + } + + function chapterSearchModule() { + if (!readerContext.chapterSearchModulePromise) + readerContext.chapterSearchModulePromise = import("/static/reader/search/reader-chapter-search.mjs").catch((error) => { + readerContext.chapterSearchModulePromise = null; + throw error; + }); + return readerContext.chapterSearchModulePromise; + } + + async function searchChapterBook(query, generation) { + if (!readerContext.chapterSearchConfiguration) throw new Error("本书暂无完整全文搜索索引"); + const module = await readerContext.chapterSearchModule(); + if (!readerContext.isReaderGenerationCurrent("search", generation)) return; + if (readerContext.chapterSearchClient?.failed) { + readerContext.chapterSearchClient.dispose(); + readerContext.chapterSearchClient = null; + } + if (!readerContext.chapterSearchClient) + readerContext.chapterSearchClient = module.createChapterSearch(readerContext.chapterSearchConfiguration); + const page = await readerContext.chapterSearchClient.search(query, partial => { + if (!readerContext.isReaderGenerationCurrent("search", generation)) return; + readerContext.applyChapterSearchPage(partial); + readerContext.renderFullSearchResults(); + readerContext.fullSearchStatus.textContent = partial.total + ? `${partial.total} 个结果(正在搜索… ${partial.scanned} / ${partial.chapters} 章)` + : `正在搜索正文… ${partial.scanned} / ${partial.chapters} 章`; + }); + if (readerContext.isReaderGenerationCurrent("search", generation)) readerContext.applyChapterSearchPage(page); + } + + async function navigateChapterSearchResult(result, generation) { + const article = await readerContext.chapterManifestLoader?.(result.chapterIndex); + if (!article || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + const module = await readerContext.chapterSearchModule(); + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + readerContext.clearFullSearchMarks(); + const mapping = module.chapterTextMap([article._chapterSearchHead, article]); + const match = result.snippet.text.slice( + result.snippet.matchStart, + result.snippet.matchStart + result.snippet.matchLength + ); + let start = result.start; + if (mapping.text.slice(start, start + result.length) !== match) { + // A browser may repair malformed source HTML differently from HTMLParser. + // Relocate only by a unique full excerpt; never guess between repeated hits. + const found = mapping.text.indexOf(result.snippet.text); + if (found < 0 || mapping.text.indexOf(result.snippet.text, found + 1) >= 0) + throw new Error("已加载章节,但未能准确定位这处文字"); + start = found + result.snippet.matchStart; + } + const parts = mapping.parts(start, start + result.length, article); + const headText = module.chapterTextMap([article._chapterSearchHead]).text; + if (start < headText.length) { + const heading = document.createElement("p"); + heading.className = "reader-chapter-search-heading"; + heading.textContent = headText; + article.prepend(heading); + parts.unshift({ node: heading.firstChild, start, end: Math.min(start + result.length, headText.length) }); + } + const [mark] = readerContext.highlightTextParts(parts); + readerContext.foliateScrollAnchors.invalidate(); + (mark || article).scrollIntoView({ block: "center" }); + readerContext.foliateScrollAnchors.remember(); + const tocIndex = readerContext.navigationState.tocEntries.findIndex( + (entry) => entry.chapterIndex === result.chapterIndex + ); + if (tocIndex >= 0) { + readerContext.updateNavigationState({ currentChapterIndex: tocIndex }); + readerContext.updateTocCurrentMark(); + } + readerContext.updateProgressTools(); + readerContext.scheduleSave(); + return true; + } + + async function fullSearchFoliateMatches(query, generation) { + if (!readerContext.epubRendition?.search) return []; + const groups = [], firstPage = []; + // Cache only visited pages for this query; never retain every match in the book. + const cachedPages = new Map(); + let total = 0; + const toResult = (item, label, occurrence) => { + const excerpt = item.excerpt || { pre: "", match: query, post: "" }; + return { + location: label || "电子书位置", + cfi: item.cfi, + occurrence, + snippet: { + text: `${excerpt.pre}${excerpt.match}${excerpt.post}`, + matchStart: excerpt.pre.length, + matchLength: excerpt.match.length, + prefix: "", + suffix: "" + } + }; + }; + readerContext.fullSearchSession.pageLoader = async (requestedOffset, pageGeneration = readerContext.readerRuntime.currentGeneration("search")) => { + const pageSize = 50; + const offset = readerContext.fullSearchPageOffset(total, requestedOffset, pageSize); + if (!offset) return { total, offset, pageSize, results: firstPage.slice() }; + const cached = cachedPages.get(offset); + if (cached) { + cachedPages.delete(offset); + cachedPages.set(offset, cached); + return cached; + } + const results = []; + let passed = 0; + for (const group of groups) { + if (passed + group.count <= offset) { passed += group.count; continue; } + const sectionOffset = Math.max(0, offset - passed); + const limit = Math.min(pageSize - results.length, group.count - sectionOffset); + for await (const batch of readerContext.epubRendition.search({ + query, index: group.index, offset: sectionOffset, limit, lean: true, + current: () => readerContext.isReaderGenerationCurrent("search", pageGeneration) + })) + for (const [index, item] of batch.subitems.entries()) + results.push(toResult(item, group.label, sectionOffset + index)); + passed += group.count; + if (results.length === pageSize) break; + } + const page = { total, offset, pageSize, results }; + if (readerContext.isReaderGenerationCurrent("search", pageGeneration) && JSON.stringify(page).length <= 128 * 1024) { + cachedPages.set(offset, page); + if (cachedPages.size > 4) cachedPages.delete(cachedPages.keys().next().value); + } + return page; + }; + for await (const group of readerContext.epubRendition.search({ + query, lean: true, limit: 50, + current: () => readerContext.isReaderGenerationCurrent("search", generation) + })) { + if (!readerContext.isReaderGenerationCurrent("search", generation)) return []; + if (group.count) groups.push({ index: group.index, count: group.count, label: group.label }); + for (const [index, item] of group.subitems.entries()) { + if (firstPage.length === 50) break; + firstPage.push(toResult(item, group.label, index)); + } + total += group.count; + readerContext.fullSearchSession.page = { total, offset: 0, pageSize: 50, results: firstPage.slice() }; + readerContext.updateSearchState({ results: readerContext.fullSearchSession.page.results, index: -1 }); + readerContext.renderFullSearchResults(); + readerContext.fullSearchStatus.textContent = `${total} 个结果(正在搜索…)`; + await readerContext.waitForReader(); + if (!readerContext.isReaderGenerationCurrent("search", generation)) return []; + } + readerContext.fullSearchSession.page = { total, offset: 0, pageSize: 50, results: firstPage.slice() }; + return firstPage; + } + + async function navigateFoliateSearchResult(result, generation) { + const resolved = await readerContext.epubRendition?.resolveNavigation?.(result.cfi); + if (!resolved || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + const section = readerContext.epubBook?.sections?.[resolved.index], + sections = readerContext.epubBook?.sections?.filter((item) => item.linear !== "no") || [], + visibleIndex = section ? sections.indexOf(section) : -1; + if (visibleIndex < 0 || !readerContext.foliateSectionLoader) return false; + let node = await readerContext.foliateSectionLoader(visibleIndex); + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + await readerContext.waitForReader(0, true); + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + if (!node?.isConnected) node = await readerContext.foliateSectionLoader(visibleIndex); + if (!node?.isConnected || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + readerContext.clearFullSearchMarks(); + let target; + if (typeof resolved.anchor === "function" && node._resolveSearchAnchor) { + const parts = node._resolveSearchAnchor(resolved.anchor); + if (!parts?.length) return false; + [target] = readerContext.highlightTextParts(parts); + } else { + // Some non-CFI engines expose only a section locator. + const matches = await readerContext.fullSearchDomMatches( + readerContext.foliateSectionRoot(node), + "电子书位置", + readerContext.searchState.query, + readerContext.readerRuntime.currentGeneration("search"), + false, + result.occurrence || 0, + generation + ); + target = matches[0]?.target; + } + if (!readerContext.isReaderGenerationCurrent("navigation", generation) || !target) return false; + const targetRect = target.getBoundingClientRect(), + viewportRect = readerContext.viewport.getBoundingClientRect(); + readerContext.foliateScrollAnchors.invalidate(); + readerContext.viewport.scrollTop = Math.max( + 0, + readerContext.viewport.scrollTop + + targetRect.top - + viewportRect.top - + (readerContext.viewport.clientHeight - targetRect.height) / 2 + ); + const tocIndex = readerContext.navigationState.tocEntries.findIndex( + (entry) => entry.sectionIndex === visibleIndex + ); + if (tocIndex >= 0) { + readerContext.updateNavigationState({ currentChapterIndex: tocIndex }); + readerContext.updateTocCurrentMark(); + } + readerContext.updateProgressTools(); + readerContext.scheduleSave(); + readerContext.foliateScrollAnchors.remember(); + return true; + } + + async function searchReaderText(query, generation) { + if ((readerContext.capability.mode === "pdf" || readerContext.pdfHybridUrl) && !readerContext.ocrManifestUrl) { + if (readerContext.pdfHybridUrl) await readerContext.pdfHybridDocumentPromise; + await readerContext.fullSearchPdfMatches(query, generation); + } + else if (["pdf", "pdf-pages"].includes(readerContext.capability.mode)) + readerContext.publishSearchResults(generation, await readerContext.searchConcentratedPdf(query, generation)); + else if (readerContext.capability.mode === "foliate") + readerContext.publishSearchResults(generation, await readerContext.fullSearchFoliateMatches(query, generation)); + else if (readerContext.capability.mode === "epub-chapters") await readerContext.searchChapterBook(query, generation); + else if (["text", "markdown", "docx"].includes(readerContext.capability.mode)) + readerContext.publishSearchResults( + generation, + await readerContext.fullSearchDomMatches(readerContext.content, "阅读位置", query, generation, true) + ); + else if (readerContext.capability.mode === "html") + readerContext.publishSearchResults( + generation, + readerContext.htmlFrame?.contentDocument?.body + ? await readerContext.fullSearchDomMatches( + readerContext.htmlFrame.contentDocument.body, + "HTML 阅读位置", + query, + generation, + true + ) + : [] + ); + else throw new Error("此格式没有可搜索文本"); + } + + return { + updateSearchState, + publishSearchResults, + clearSearchHighlights, + chapterSearchModule, + searchChapterBook, + navigateChapterSearchResult, + fullSearchFoliateMatches, + navigateFoliateSearchResult, + searchReaderText + }; +} diff --git a/static/reader-search-controller.mjs b/static/reader/search/reader-search-controller.mjs similarity index 100% rename from static/reader-search-controller.mjs rename to static/reader/search/reader-search-controller.mjs diff --git a/static/reader-chapter-search-worker.mjs b/static/reader/search/workers/reader-chapter-search-worker.mjs similarity index 100% rename from static/reader-chapter-search-worker.mjs rename to static/reader/search/workers/reader-chapter-search-worker.mjs diff --git a/static/reader-pdf-book-search-worker.mjs b/static/reader/search/workers/reader-pdf-book-search-worker.mjs similarity index 99% rename from static/reader-pdf-book-search-worker.mjs rename to static/reader/search/workers/reader-pdf-book-search-worker.mjs index 77d3826382c22cd77e4dfde152ca68b41b91b815..2862718e22a8c7800cfb80a2a748151c071a07e4 100644 --- a/static/reader-pdf-book-search-worker.mjs +++ b/static/reader/search/workers/reader-pdf-book-search-worker.mjs @@ -1,5 +1,5 @@ -import "./reader-security.js"; -import { searchBookText, validateBookText } from "./reader-book-text.js"; +import "../../core/reader-security.js"; +import { searchBookText, validateBookText } from "../reader-book-text.js"; const aborted = () => new DOMException("Search cancelled", "AbortError"); const limit = self.VoiceOfMLReaderSecurity.LIMITS.chapterTotalBytes; diff --git a/static/reader/ui/reader-bookmarks.mjs b/static/reader/ui/reader-bookmarks.mjs new file mode 100644 index 0000000000000000000000000000000000000000..943f30a12999d0c075a213f4b0ff15b840b85cab --- /dev/null +++ b/static/reader/ui/reader-bookmarks.mjs @@ -0,0 +1,411 @@ +export function createReaderBookmarks(readerContext) { + function consumeBookmarkHandoff(entry) { + if (!entry || entry.id !== readerContext.params.get("bookmark") || entry.url !== readerContext.params.get("bookmark_source")) + return; + const opening = new URL(location.href); + const shareKey = (url) => { + const key = new URL(url); + key.searchParams.delete("return"); + key.searchParams.delete("nav"); + return key.href; + }; + const cleanMatchingUrl = (raw) => { + if (typeof raw !== "string" || !raw) return null; + try { + const url = new URL(raw, location.origin); + if ( + url.origin !== location.origin || + url.pathname !== opening.pathname || + shareKey(url) !== shareKey(opening) + ) + return null; + url.searchParams.delete("bookmark"); + url.searchParams.delete("bookmark_source"); + return url.href; + } catch (_) { + return null; + } + }; + const cleanHistory = (owner) => { + const url = cleanMatchingUrl(owner.location.href); + if (!url) return; + const state = owner.history.state; + const readerUrl = state?.voiceReaderOverlay && cleanMatchingUrl(state.readerUrl); + owner.history.replaceState(readerUrl ? { ...state, readerUrl } : state, "", url); + }; + // Clean only this navigation, not a bookmark-ID cache. A new deliberate click + // can reuse the same bookmark; overlay reload/Back uses the cleaned records. + try { + if ( + window.parent !== window && + window.parent.location.origin === location.origin && + window.frameElement?.matches("iframe.reader-overlay") && + window.parent.history.state?.voiceReaderOverlay === true && + cleanMatchingUrl(window.parent.history.state.readerUrl) + ) + cleanHistory(window.parent); + } catch (_) {} + try { + const saved = JSON.parse(sessionStorage.getItem("reader-navigation-current") || "null"); + const readerUrl = cleanMatchingUrl(saved?.readerUrl), + shareUrl = cleanMatchingUrl(saved?.shareUrl); + if (readerUrl && shareUrl) + sessionStorage.setItem( + "reader-navigation-current", + JSON.stringify({ ...saved, readerUrl, shareUrl }) + ); + } catch (_) {} + try { + cleanHistory(window); + } catch (_) {} + readerContext.params.delete("bookmark"); + readerContext.params.delete("bookmark_source"); + } + + function excerptFromCaret(doc, root, x, y) { + let node, + offset = 0; + const position = doc.caretPositionFromPoint ? doc.caretPositionFromPoint(x, y) : null; + if (position) { + node = position.offsetNode; + offset = position.offset; + } else if (doc.caretRangeFromPoint) { + const range = doc.caretRangeFromPoint(x, y); + if (range) { + node = range.startContainer; + offset = range.startOffset; + } + } + if (!node || !root.contains(node)) return ""; + if (node.nodeType !== Node.TEXT_NODE) { + const first = doc.createTreeWalker(node, NodeFilter.SHOW_TEXT).nextNode(); + if (!first) return ""; + node = first; + offset = 0; + } + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT); + walker.currentNode = node; + let excerpt = node.data.slice(offset); + while (excerpt.length < 220 && walker.nextNode()) excerpt += ` ${walker.currentNode.data}`; + return excerpt.replace(/\s+/g, " ").trim().slice(0, 160); + } + + function bookmarkExcerpt() { + if (["image", "audio", "video", "pdf-pages"].includes(readerContext.capability.mode)) return ""; + if (readerContext.capability.mode === "pdf") { + const shell = readerContext.pageAtMarker(), + items = shell && shell._bookmarkTextItems; + if (items && items.length) { + const rect = shell.getBoundingClientRect(), + targetY = + Math.max(0, readerContext.bookmarkRibbon.getBoundingClientRect().bottom - rect.top) / + Math.max(1, rect.height); + let nearest = 0, + distance = Infinity; + items.forEach((item, index) => { + const nextDistance = Math.abs(item.y - targetY); + if (nextDistance < distance) { + nearest = index; + distance = nextDistance; + } + }); + return items + .slice(nearest) + .map((item) => item.text) + .join(" ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 160); + } + // A scanned/unloaded page has no excerpt. Never fall back to other pages' UI text. + return ""; + } + const x = Math.round(readerContext.viewport.getBoundingClientRect().width / 2), + y = Math.round(readerContext.bookmarkRibbon.getBoundingClientRect().bottom + 8); + const frames = [...document.querySelectorAll("iframe")].filter((frame) => { + const rect = frame.getBoundingClientRect(); + return rect.left <= x && rect.right >= x && rect.top <= y && rect.bottom >= y; + }); + const frame = frames[frames.length - 1]; + try { + if (frame && frame.contentDocument && frame.contentDocument.body) { + const rect = frame.getBoundingClientRect(); + return readerContext.excerptFromCaret( + frame.contentDocument, + frame.contentDocument.body, + x - rect.left, + y - rect.top + ); + } + } catch (_) {} + const exact = readerContext.excerptFromCaret(document, readerContext.content, x, y); + if (exact) return exact; + const sourceText = readerContext.foliateContinuous + ? [ + ...readerContext.content.querySelectorAll( + ".foliate-continuous article[data-section]:not(.foliate-section-placeholder)" + ) + ] + .map((article) => readerContext.foliateSectionRoot(article).textContent) + .join(" ") + : readerContext.content.textContent, + text = sourceText.replace(/\s+/g, " ").trim(), + start = Math.floor((text.length * readerContext.readerProgressPercent()) / 100); + return text.slice(start, start + 160).trim(); + } + + function captureFoliateBookmarkPosition() { + if (!readerContext.foliateContinuous) return null; + const marker = readerContext.viewport.getBoundingClientRect().top + 8, + rows = [...document.querySelectorAll(".foliate-continuous article[data-section]")]; + const article = + rows.find((row) => { + const rect = row.getBoundingClientRect(); + return rect.top <= marker && rect.bottom > marker; + }) || + rows.find((row) => row.getBoundingClientRect().top > marker) || + rows[rows.length - 1]; + return article + ? { + foliateSection: Number(article.dataset.section), + foliateOffset: marker - article.getBoundingClientRect().top, + foliateTocIndex: readerContext.navigationState.currentChapterIndex + } + : null; + } + + async function restoreFoliateBookmarkPosition(entry, generation = readerContext.beginReaderNavigation()) { + try { + if ( + !readerContext.foliateSectionLoader || + !Number.isInteger(entry.foliateSection) || + !readerContext.isReaderGenerationCurrent("navigation", generation) + ) + return false; + let article = await readerContext.foliateSectionLoader(entry.foliateSection); + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + await readerContext.waitForReader(0, true); + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + if (!article?.isConnected) article = await readerContext.foliateSectionLoader(entry.foliateSection); + if (!article?.isConnected || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + const marker = readerContext.viewport.getBoundingClientRect().top + 8; + const offset = Number(entry.foliateOffset) || 0; + const start = !offset + ? readerContext.foliateSectionRoot(article).querySelector("h1,h2,h3,h4,h5,h6,p,li,blockquote,pre") || article + : article; + readerContext.foliateScrollAnchors.invalidate(); + readerContext.viewport.scrollTop = Math.max( + 0, + readerContext.viewport.scrollTop + + start.getBoundingClientRect().top - + marker + + offset + ); + readerContext.syncFoliateScrollLocation(); + readerContext.updateTocCurrentMark(); + readerContext.updateProgressTools(); + readerContext.scheduleSave(); + readerContext.setReaderPanelOpen(false, true); + readerContext.foliateScrollAnchors.remember(); + return true; + } catch (error) { + readerContext.reportNavigationError(error, generation); + return false; + } + } + + async function seekFoliateProgress(percent, generation) { + try { + const sections = readerContext.epubBook?.sections?.filter((section) => section.linear !== "no") || []; + if ( + !sections.length || + !readerContext.foliateSectionLoader || + !readerContext.isReaderGenerationCurrent("navigation", generation) + ) + return; + const position = Math.max(0, Math.min(0.999999, Number(percent) / 100)) * sections.length, + index = Math.min(sections.length - 1, Math.floor(position)); + let article = await readerContext.foliateSectionLoader(index); + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return; + await readerContext.waitForReader(0, true); + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return; + if (!article?.isConnected) article = await readerContext.foliateSectionLoader(index); + if (!article?.isConnected || !readerContext.isReaderGenerationCurrent("navigation", generation)) return; + const marker = readerContext.viewport.getBoundingClientRect().top + 8, + offset = article.getBoundingClientRect().height * (position - index); + readerContext.foliateScrollAnchors.invalidate(); + readerContext.viewport.scrollTop = Math.max( + 0, + readerContext.viewport.scrollTop + article.getBoundingClientRect().top - marker + offset + ); + await readerContext.waitForReader(0, true); + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return; + readerContext.viewport.scrollTop += article.getBoundingClientRect().top - marker + offset + 4; + readerContext.syncFoliateScrollLocation(); + readerContext.updateTocCurrentMark(); + readerContext.updateProgressTools(); + readerContext.scheduleSave(); + readerContext.foliateScrollAnchors.remember(); + } catch (error) { + readerContext.reportNavigationError(error, generation); + } + } + + async function renderBookmarks() { + const list = document.querySelector("#bookmarks-list"), + generation = readerContext.nextReaderGeneration("bookmarks"); + try { + const entries = readerContext.panelState.showingAllBookmarks + ? await VoiceOfMLReaderStore.listAllBookmarks() + : await VoiceOfMLReaderStore.listBookmarks(readerContext.sourceUrl); + if (!readerContext.isReaderGenerationCurrent("bookmarks", generation)) return; + list.textContent = ""; + for (const entry of entries) { + const row = document.createElement("div"); + row.className = "panel-item"; + row.dataset.bookmarkId = entry.id; + const open = document.createElement("button"); + open.type = "button"; + open.className = "panel-item-main"; + open.textContent = readerContext.panelState.showingAllBookmarks + ? `${entry.title || "未命名书籍"} · ${entry.label}` + : entry.label; + open.addEventListener("click", () => + entry.url !== readerContext.sourceUrl + ? readerContext.navigateReader(entry.readerUrl, entry) + : readerContext.restoreFormat(readerContext.capability.mode, entry) + ); + const excerpt = document.createElement("p"); + excerpt.className = "bookmark-excerpt"; + excerpt.textContent = entry.excerpt || ""; + excerpt.hidden = !entry.excerpt; + const meta = document.createElement("small"); + meta.textContent = new Date(entry.createdAt).toLocaleString(); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "panel-item-remove"; + remove.textContent = "删除"; + remove.addEventListener("click", async () => { + await VoiceOfMLReaderStore.removeBookmark(entry.id); + row.remove(); + if (!list.querySelector(".panel-item")) readerContext.emptyPanel(list, "暂无书签"); + }); + const edit = document.createElement("button"); + edit.type = "button"; + edit.className = "panel-item-edit"; + edit.textContent = "编辑"; + edit.addEventListener("click", () => readerContext.openBookmarkPopover(edit, entry)); + row.append(open, edit, remove, excerpt, meta); + list.appendChild(row); + } + if (!list.childElementCount) readerContext.emptyPanel(list, "暂无书签"); + readerContext.filterPanel(document.querySelector("#bookmarks-panel")); + } catch (_) { + if (readerContext.isReaderGenerationCurrent("bookmarks", generation)) readerContext.emptyPanel(list, "无法读取书签"); + } + } + + function captureBookmarkSnapshot() { + if (readerContext.mediaElement) { + const time = Number.isFinite(readerContext.mediaElement.currentTime) ? readerContext.mediaElement.currentTime : 0; + return { + locator: `media:${Math.round(time * 10) / 10}`, + label: `时间 ${readerContext.formatMediaTime(time)}`, + excerpt: "", + progress: readerContext.mediaElement.duration ? Math.round((time / readerContext.mediaElement.duration) * 1000) / 10 : 0, + mediaTime: time, + page: 0, + pageOffset: 0, + scrollTop: 0, + htmlScrollTop: 0 + }; + } + readerContext.syncCurrentPageFromMarker(); + const foliatePosition = readerContext.captureFoliateBookmarkPosition() || readerContext.captureChapterPosition(); + const shell = readerContext.documentState.pageCount ? readerContext.pageAtMarker() : null; + const pageOffset = shell ? Math.max(0, readerContext.viewport.scrollTop - readerContext.readerPageScrollTop(shell)) : 0; + const progress = readerContext.readerProgressPercent(); + const htmlScrollTop = readerContext.htmlFrame && readerContext.htmlFrame.contentWindow ? readerContext.htmlFrame.contentWindow.scrollY : 0; + const scrollTop = readerContext.viewport.scrollTop; + const pagedDocument = readerContext.documentState.pageCount && !["text", "markdown"].includes(readerContext.capability.mode); + const locator = Number.isInteger(foliatePosition?.chapterIndex) + ? `chapter:${foliatePosition.chapterIndex}:${Math.round(foliatePosition.chapterOffset)}` + : foliatePosition + ? `foliate:${foliatePosition.foliateSection}:${Math.round(foliatePosition.foliateOffset)}` + : pagedDocument + ? `page:${readerContext.documentState.page}:${Math.round(pageOffset)}` + : `progress:${progress}:${Math.round(readerContext.htmlFrame ? htmlScrollTop : scrollTop)}`; + return { + locator, + label: pagedDocument + ? `第 ${readerContext.documentState.page} / ${readerContext.documentState.pageCount} 页` + : `阅读进度 ${progress.toFixed(1)}%`, + excerpt: readerContext.bookmarkExcerpt(), + progress, + page: pagedDocument ? readerContext.documentState.page : 0, + pageOffset, + scrollTop, + htmlScrollTop, + ...(foliatePosition || {}) + }; + } + + function setBookmarkDialogModal(open) { + document.documentElement.classList.toggle("bookmark-dialog-open", open); + if (open) { + for (let node = readerContext.bookmarkPopover; node && node !== document.body; node = node.parentElement) { + for (const sibling of node.parentElement.children) + if (sibling !== node && !readerContext.bookmarkInertSiblings.has(sibling)) { + readerContext.bookmarkInertSiblings.set(sibling, sibling.inert); + sibling.inert = true; + } + } + } else { + for (const [node, inert] of readerContext.bookmarkInertSiblings) node.inert = inert; + readerContext.bookmarkInertSiblings.clear(); + } + } + + function openBookmarkPopover(invoker = readerContext.bookmarkRibbon, entry = null) { + if (!readerContext.bookmarkPopover.hidden) return; + readerContext.bookmarkInvoker = invoker; + readerContext.pendingBookmarkSnapshot = entry ? null : readerContext.captureBookmarkSnapshot(); + readerContext.updatePanelState({ editingBookmark: entry ? { ...entry } : null }); + const snapshot = entry || readerContext.pendingBookmarkSnapshot; + document.querySelector("#bookmark-prompt").textContent = entry + ? `编辑书签 · ${entry.label}` + : `在${snapshot.label}添加书签?`; + readerContext.bookmarkLabelInput.value = snapshot.label || ""; + readerContext.bookmarkExcerptInput.value = snapshot.excerpt || ""; + document.querySelector("#bookmark-add").textContent = entry ? "保存书签" : "添加书签"; + readerContext.bookmarkPopover.hidden = false; + invoker.setAttribute("aria-expanded", "true"); + invoker.setAttribute("aria-controls", readerContext.bookmarkPopover.id); + readerContext.bookmarkLabelInput.focus(); + readerContext.setBookmarkDialogModal(true); + } + + function closeBookmarkPopover() { + readerContext.pendingBookmarkSnapshot = null; + readerContext.updatePanelState({ editingBookmark: null }); + readerContext.bookmarkPopover.hidden = true; + readerContext.setBookmarkDialogModal(false); + readerContext.bookmarkInvoker?.setAttribute("aria-expanded", "false"); + (readerContext.bookmarkInvoker?.isConnected ? readerContext.bookmarkInvoker : readerContext.bookmarkRibbon).focus({ preventScroll: true }); + readerContext.bookmarkInvoker = null; + } + + return { + consumeBookmarkHandoff, + excerptFromCaret, + bookmarkExcerpt, + captureFoliateBookmarkPosition, + restoreFoliateBookmarkPosition, + seekFoliateProgress, + renderBookmarks, + captureBookmarkSnapshot, + setBookmarkDialogModal, + openBookmarkPopover, + closeBookmarkPopover + }; +} diff --git a/static/reader/ui/reader-document-styles.mjs b/static/reader/ui/reader-document-styles.mjs new file mode 100644 index 0000000000000000000000000000000000000000..c6e996dcb9772ae410970a84b8067b3cdd4e7798 --- /dev/null +++ b/static/reader/ui/reader-document-styles.mjs @@ -0,0 +1,43 @@ +export const EPUB_HTML_TAGS = new Set( + "a,abbr,address,area,article,aside,audio,b,base,bdi,bdo,blockquote,body,br,button,canvas,caption,cite,code,col,colgroup,data,datalist,dd,del,details,dfn,dialog,div,dl,dt,em,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,i,iframe,img,input,ins,kbd,label,legend,li,link,main,map,mark,menu,meta,meter,nav,noscript,object,ol,optgroup,option,output,p,picture,pre,progress,q,rb,rp,rt,rtc,ruby,s,samp,script,search,section,select,slot,small,source,span,strong,style,sub,summary,sup,table,tbody,td,template,textarea,tfoot,th,thead,time,title,tr,track,u,ul,var,video,wbr".split( + "," + ) +); + +export const EPUB_HTML_VOID_TAGS = new Set( + "area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr".split(",") +); + +export const HTML_FRAME_STYLE = + ''; + +export const DISCOURSE_POST_SELECTOR = ".topic-post .cooked"; + +export const DISCOURSE_TITLE_SELECTOR = "#topic-title .fancy-title, .topic-link, title"; + +export const DISCOURSE_HTML_STYLE = ``; + +export const SPREADSHEET_HTML_STYLE = ``; diff --git a/static/reader/ui/reader-navigation.mjs b/static/reader/ui/reader-navigation.mjs new file mode 100644 index 0000000000000000000000000000000000000000..97a97f369a457120e25adfc6ee73c3b81561ed9b --- /dev/null +++ b/static/reader/ui/reader-navigation.mjs @@ -0,0 +1,216 @@ +export function createReaderNavigation(readerContext) { + function beginReaderNavigation() { + if (readerContext.epubSeekFrame) cancelAnimationFrame(readerContext.epubSeekFrame); + readerContext.epubSeekFrame = 0; + readerContext.foliateScrollAnchors.invalidate(); + return readerContext.nextReaderGeneration("navigation"); + } + + function clearReturnNavigation() { + try { + if (readerContext.returnHistoryKey) sessionStorage.removeItem(readerContext.returnHistoryKey); + const saved = JSON.parse(sessionStorage.getItem("reader-navigation-current") || "null"); + if (saved && (saved.readerUrl === location.href || saved.shareUrl === location.href)) + sessionStorage.removeItem("reader-navigation-current"); + } catch (_) {} + } + + function navigateReaderFolder() { + if (!readerContext.folderNavigationTarget) return; + if (window.parent !== window) + window.parent.postMessage( + { type: "voice-reader-navigate", url: readerContext.folderNavigationTarget.href }, + location.origin + ); + else { + readerContext.clearReturnNavigation(); + location.assign(readerContext.folderNavigationTarget.href); + } + } + + function turnViewport(direction) { + readerContext.beginReaderNavigation(); + const target = readerContext.htmlFrame?.contentWindow || readerContext.viewport; + target.scrollBy({ + top: + Math.max(160, (readerContext.htmlFrame?.contentWindow?.innerHeight || readerContext.viewport.clientHeight) * 0.86) * + direction, + behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth" + }); + } + + function handleReaderKeydown(event) { + if ( + event.defaultPrevented || + event.isComposing || + event.altKey || + event.ctrlKey || + event.metaKey || + event.target.closest?.( + "input,textarea,select,button,a,audio,video,[contenteditable]:not([contenteditable=false])" + ) + ) + return; + if (["PageDown", " ", "ArrowDown", "PageUp", "ArrowUp"].includes(event.key)) { + event.preventDefault(); + readerContext.turnViewport( + ["PageUp", "ArrowUp"].includes(event.key) || (event.key === " " && event.shiftKey) ? -1 : 1 + ); + } + } + + function readerPageScrollTop(shell) { + return readerContext.viewport.scrollTop + shell.getBoundingClientRect().top - + readerContext.viewport.getBoundingClientRect().top - readerContext.viewport.clientTop; + } + + async function goToPage(value, generation = readerContext.beginReaderNavigation()) { + try { + if (!readerContext.documentState.pageCount || !readerContext.isReaderGenerationCurrent("navigation", generation)) + return false; + const page = VoiceOfMLReader.clampNumber(value, 1, readerContext.documentState.pageCount, 1); + readerContext.pdfShellWindow?.ensure(page); + let shell = readerContext.content.querySelector( + `.reader-page[data-page="${page}"], .reader-docx-page[data-page="${page}"]` + ); + if (!shell && ["pdf-pages", "image-pages"].includes(readerContext.capability.mode)) { + await readerContext.pdfShellsReady; + shell = readerContext.content.querySelector(`.reader-page[data-page="${page}"]`); + } + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + if (!shell) return false; + const imagePage = ["pdf-pages", "image-pages"].includes(readerContext.capability.mode); + if (imagePage && shell.dataset.renderState !== "rendered") + readerContext.prioritizePdfManifestPrefetch(readerContext.pdfPageManifest?.pageUrl(page)); + const pendingPdf = + !imagePage && shell.classList.contains("reader-page") + ? readerContext.renderPdfInBackground(shell, false, true) + : null; + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + readerContext.viewport.scrollTop = readerContext.readerPageScrollTop(shell); + if (imagePage && shell.dataset.renderState === "rendered") readerContext.renderPdfOcrText(shell); + readerContext.pageNavigationLockUntil = performance.now() + 500; + if (pendingPdf) readerContext.cancelSpeculativePdfRenders(shell); + const pendingImage = imagePage ? readerContext.renderPdfInBackground(shell, false, true) : null; + if (imagePage && shell.dataset.renderState !== "rendered") readerContext.cancelSpeculativePdfRenders(shell); + readerContext.updateDocumentState({ page }); + readerContext.pageInput.value = String(page); + readerContext.updateTocCurrentMark(); + readerContext.updateProgressTools(); + pendingPdf?.catch(() => {}); + pendingImage?.catch(() => {}); + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + readerContext.scheduleSave(); + return true; + } catch (error) { + readerContext.handlePageNavigationFailure(error, generation); + return false; + } + } + + function handleReaderPositionChange() { + if (readerContext.readerAbortController.signal.aborted) return; + if (readerContext.viewport.scrollTop !== readerContext.pdfLastScrollTop) readerContext.pdfScrollDirection = readerContext.viewport.scrollTop > readerContext.pdfLastScrollTop ? 1 : -1; + readerContext.pdfLastScrollTop = readerContext.viewport.scrollTop; + readerContext.pdfShellWindow?.schedule(); + readerContext.scheduleMarkerSync(); + readerContext.scheduleFoliateScrollSync(); + readerContext.foliateScrollAnchors.remember(); + } + + async function navigateReader(rawUrl, bookmark = null) { + let target; + try { + target = new URL(rawUrl, location.origin); + if ( + target.origin !== location.origin || + target.pathname !== "/static/reader.html" || + target.username || + target.password + ) + return; + target.searchParams.delete("bookmark"); + target.searchParams.delete("bookmark_source"); + if (bookmark) { + if ( + typeof bookmark.id !== "string" || + !bookmark.id || + typeof bookmark.url !== "string" || + !bookmark.url + ) + return; + target.searchParams.set("bookmark", bookmark.id); + target.searchParams.set("bookmark_source", bookmark.url); + } + } catch (_) { + return; + } + const generation = readerContext.beginReaderNavigation(); + await readerContext.saveProgress(); + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return; + if (window.parent !== window) + window.parent.postMessage({ type: "voice-reader-open", url: target.href }, location.origin); + else location.assign(target.href); + } + + function notePdfScrollIntent() { + readerContext.pdfUserHasScrolled = true; + readerContext.beginReaderNavigation(); + } + + function installReaderTapSelectionClear(region, eventRoot = region.ownerDocument) { + let tap = null; + const down = (event) => { + tap = null; + if (event.pointerType !== "touch" || !event.isPrimary) return; + const target = event.composedPath()[0]; + if (target.closest?.("a,button,input,textarea,select,[contenteditable]")) return; + const root = target.getRootNode(); + const selection = root.getSelection?.() || eventRoot.getSelection(); + if (!selection || selection.isCollapsed || !selection.rangeCount) return; + tap = { id: event.pointerId, x: event.clientX, y: event.clientY, time: performance.now(), + selection, anchor: selection.anchorNode, anchorOffset: selection.anchorOffset, + focus: selection.focusNode, focusOffset: selection.focusOffset }; + }; + const move = (event) => { + if (tap && event.pointerId === tap.id && Math.hypot(event.clientX - tap.x, event.clientY - tap.y) > 8) + tap = null; + }; + const up = (event) => { + const start = tap; + tap = null; + if (!start || event.pointerId !== start.id || performance.now() - start.time > 300 || + Math.hypot(event.clientX - start.x, event.clientY - start.y) > 8) return; + const selection = start.selection; + if (selection.anchorNode === start.anchor && selection.anchorOffset === start.anchorOffset && + selection.focusNode === start.focus && selection.focusOffset === start.focusOffset) + selection.removeAllRanges(); + }; + const cancel = () => { tap = null; }; + region.addEventListener("pointerdown", down, { passive: true }); + eventRoot.addEventListener("pointermove", move, { passive: true }); + eventRoot.addEventListener("pointerup", up, { passive: true }); + eventRoot.addEventListener("pointercancel", cancel, { passive: true }); + readerContext.trackReaderResource(() => { + region.removeEventListener("pointerdown", down); + eventRoot.removeEventListener("pointermove", move); + eventRoot.removeEventListener("pointerup", up); + eventRoot.removeEventListener("pointercancel", cancel); + tap = null; + }); + } + + return { + beginReaderNavigation, + clearReturnNavigation, + navigateReaderFolder, + turnViewport, + handleReaderKeydown, + readerPageScrollTop, + goToPage, + handleReaderPositionChange, + navigateReader, + notePdfScrollIntent, + installReaderTapSelectionClear + }; +} diff --git a/static/reader/ui/reader-progress.mjs b/static/reader/ui/reader-progress.mjs new file mode 100644 index 0000000000000000000000000000000000000000..75e7a881bc57e6a7348bc5c9479140d3f845ce95 --- /dev/null +++ b/static/reader/ui/reader-progress.mjs @@ -0,0 +1,458 @@ +export function createReaderProgress(readerContext) { + function scheduleSave() { + if (readerContext.readerLifecycle.disposed || readerContext.readerAbortController.signal.aborted) return; + const now = performance.now(); + if (!readerContext.saveDeadline) readerContext.saveDeadline = now + 2000; + readerContext.readerRuntime.cancel(readerContext.saveTimer); + readerContext.saveTimer = readerContext.readerRuntime.schedule( + () => { + readerContext.saveTimer = 0; + readerContext.saveDeadline = 0; + readerContext.saveProgress(); + }, + Math.max(0, Math.min(500, readerContext.saveDeadline - now)) + ); + } + + async function saveProgress(event) { + if ( + readerContext.readerLifecycle.disposed || + !readerContext.documentState.restorationReady || + !readerContext.validSource(readerContext.sourceUrl) || + readerContext.historySuppressed + ) + return; + if (event?.type !== "pagehide" && document.visibilityState !== "hidden") + readerContext.syncCurrentPageFromMarker(); + const shell = readerContext.documentState.pageCount + ? readerContext.content.querySelector( + `.reader-page[data-page="${readerContext.documentState.page}"], .reader-docx-page[data-page="${readerContext.documentState.page}"]` + ) + : null; + const pageOffset = shell ? Math.max(0, readerContext.viewport.scrollTop - readerContext.readerPageScrollTop(shell)) : 0; + const htmlScrollTop = readerContext.htmlFrame && readerContext.htmlFrame.contentWindow ? readerContext.htmlFrame.contentWindow.scrollY : 0; + const readerUrl = readerContext.currentReaderUrl(); + const foliatePosition = readerContext.captureFoliateBookmarkPosition() || readerContext.captureChapterPosition(); + const progress = { + url: readerContext.sourceUrl, + title: readerContext.documentState.title, + extension: readerContext.extension, + readerUrl: readerUrl.href, + page: readerContext.documentState.page, + pageCount: readerContext.documentState.pageCount, + pageOffset, + mediaTime: + readerContext.mediaElement && Number.isFinite(readerContext.mediaElement.currentTime) ? readerContext.mediaElement.currentTime : 0, + scrollTop: readerContext.viewport.scrollTop, + htmlScrollTop, + zoom: Math.round(readerContext.documentState.zoom * 100), + ...(foliatePosition || {}) + }; + const signature = JSON.stringify(progress); + if (signature === readerContext.lastSavedProgress) return readerContext.progressSaveChain; + readerContext.lastSavedProgress = signature; + readerContext.progressSaveChain = readerContext.progressSaveChain + .catch(() => {}) + .then(() => { + if (!readerContext.historySuppressed) + return VoiceOfMLReaderStore.put({ ...progress, lastReadAt: Date.now() }); + }) + .catch((error) => { + if (readerContext.lastSavedProgress === signature) readerContext.lastSavedProgress = ""; + console.warn("Reader progress was not saved", error); + }); + return readerContext.progressSaveChain; + } + + function placeReadingProgress() { + const panel = document.querySelector("#history-panel"), + tocPanel = document.querySelector("#toc-panel"), + tocList = tocPanel.querySelector(".panel-list"), + noToc = readerContext.loadingStatus.hidden && !readerContext.navigationState.tocEntries.length && !readerContext.mediaElement; + readerContext.progressTools.classList.toggle("reader-no-toc", noToc); + tocPanel.querySelector(".panel-view-header strong").textContent = noToc ? "阅读状态" : "目录"; + if ( + noToc && + (readerContext.progressTools.parentElement !== tocPanel || readerContext.progressTools.nextElementSibling !== tocList) + ) + tocPanel.insertBefore(readerContext.progressTools, tocList); + else if (!noToc && panel.lastElementChild !== readerContext.progressTools) panel.appendChild(readerContext.progressTools); + } + + function currentReaderUrl() { + const url = new URL(location.href); + for (const key of ["return", "nav", "bookmark", "bookmark_source"]) url.searchParams.delete(key); + return url; + } + + async function loadReaderRestoration() { + const currentEntry = (entry) => { + if ( + readerContext.capability.mode === "epub-chapters" && + /-epub-chapters-v5\//.test(readerContext.chapterManifestUrl || readerContext.sourceUrl) && + entry?.chapterManifest !== (readerContext.chapterManifestUrl || readerContext.sourceUrl) + ) return null; + return entry; + }; + try { + const bookmarkId = readerContext.params.get("bookmark"), + bookmarkSource = readerContext.params.get("bookmark_source"); + if (bookmarkId && bookmarkSource === readerContext.sourceUrl) { + const entries = await VoiceOfMLReaderStore.listBookmarks(readerContext.sourceUrl); + const bookmark = entries.find((entry) => entry.id === bookmarkId && entry.url === readerContext.sourceUrl); + if (bookmark) return currentEntry(bookmark); + } + return currentEntry(await VoiceOfMLReaderStore.get(readerContext.sourceUrl)); + } catch (_) { + readerContext.restorationFailed = true; + return null; + } + } + + async function getInitialReaderRestoration() { + if (!readerContext.readerRestorationPromise) return null; + if (!readerContext.readerRestorationLoaded) { + readerContext.readerRestorationEntry = await readerContext.awaitReader(Promise.race([ + readerContext.readerRestorationPromise, + readerContext.waitForReader(150).then(() => null) + ])); + readerContext.readerRestorationLoaded = true; + } + return readerContext.readerRestorationEntry; + } + + function initialReaderPage(total) { + const page = Number(readerContext.readerRestorationEntry?.page); + return Number.isInteger(page) ? Math.max(1, Math.min(total, page)) : 1; + } + + function readerProgressPercent() { + if (readerContext.mediaElement) + return readerContext.mediaElement.duration + ? Math.round((readerContext.mediaElement.currentTime / readerContext.mediaElement.duration) * 1000) / 10 + : 0; + if (readerContext.foliateContinuous) { + const position = readerContext.captureFoliateBookmarkPosition(), + count = readerContext.epubBook?.sections?.filter((section) => section.linear !== "no").length || 0, + article = + position && + document.querySelector( + `.foliate-continuous article[data-section="${position.foliateSection}"]` + ), + fraction = article + ? Math.max( + 0, + Math.min( + 1, + position.foliateOffset / Math.max(1, article.getBoundingClientRect().height) + ) + ) + : 0; + return count && position + ? Math.round(((position.foliateSection + fraction) / count) * 1000) / 10 + : 0; + } + if (readerContext.htmlFrame && readerContext.htmlFrame.contentDocument) { + const doc = readerContext.htmlFrame.contentDocument.documentElement, + win = readerContext.htmlFrame.contentWindow; + return ( + Math.round( + Math.max(0, Math.min(1, win.scrollY / Math.max(1, doc.scrollHeight - win.innerHeight))) * + 1000 + ) / 10 + ); + } + return ( + Math.round( + Math.max( + 0, + Math.min(1, readerContext.viewport.scrollTop / Math.max(1, readerContext.viewport.scrollHeight - readerContext.viewport.clientHeight)) + ) * 1000 + ) / 10 + ); + } + + function pageAtMarker() { + const y = readerContext.bookmarkRibbon.getBoundingClientRect().bottom, + pages = readerContext.docxPageNodes.length ? readerContext.docxPageNodes : [...readerContext.content.querySelectorAll(".reader-page, .reader-docx-page")]; + if (!pages.length) return null; + const end = readerContext.viewport.scrollHeight - readerContext.viewport.clientHeight; + if (["pdf", "pdf-pages"].includes(readerContext.capability.mode) && end > 2 && readerContext.viewport.scrollTop > 0 && + end - readerContext.viewport.scrollTop <= 2) { + const last = pages[pages.length - 1], rect = last.getBoundingClientRect(), view = readerContext.viewport.getBoundingClientRect(); + if (rect.bottom > view.top && rect.top < view.bottom) return last; + } + let low = 0, high = pages.length; + while (low < high) { + const middle = (low + high) >> 1; + if (pages[middle].getBoundingClientRect().bottom > y) high = middle; + else low = middle + 1; + } + return pages[Math.min(low, pages.length - 1)]; + } + + function syncCurrentPageFromMarker() { + if (performance.now() < readerContext.pageNavigationLockUntil) return; + const page = readerContext.pageAtMarker(); + if (!page) return; + const next = Number(page.dataset.page); + if (!next || next === readerContext.documentState.page) return; + readerContext.updateDocumentState({ page: next }); + if (document.activeElement !== readerContext.pageInput) readerContext.pageInput.value = String(next); + readerContext.updateTocCurrentMark(); + readerContext.updateProgressTools(); + } + + function scheduleMarkerSync() { + if (readerContext.markerFrame) return; + readerContext.markerFrame = requestAnimationFrame(() => { + readerContext.markerFrame = 0; + if (readerContext.readerAbortController.signal.aborted) return; + readerContext.syncCurrentPageFromMarker(); + readerContext.refreshNearbyPdfPages(); + readerContext.syncHeadingLocation(); + readerContext.updateProgressTools(); + readerContext.scheduleSave(); + }); + } + + async function restoreFormat(mode, entry, generation = readerContext.beginReaderNavigation()) { + try { + if (!entry || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + if (mode === "epub-chapters") { + if (Number.isInteger(entry.chapterIndex)) { + if (!(await readerContext.restoreChapterPosition(entry, generation))) return false; + } else if (!(await readerContext.restoreProgressState(entry, generation))) return false; + } else if (mode === "foliate" && Number.isInteger(entry.foliateSection)) { + if (!(await readerContext.restoreFoliateBookmarkPosition(entry, generation))) return false; + } else if (readerContext.documentState.pageCount && entry.page) { + if ( + !(await readerContext.goToPage(entry.page, generation)) || + !readerContext.isReaderGenerationCurrent("navigation", generation) + ) + return false; + const shell = readerContext.content.querySelector( + `.reader-page[data-page="${readerContext.documentState.page}"], .reader-docx-page[data-page="${readerContext.documentState.page}"]` + ); + if (shell && Number.isFinite(entry.pageOffset)) + readerContext.viewport.scrollTop = readerContext.readerPageScrollTop(shell) + entry.pageOffset; + readerContext.updateProgressTools(); + readerContext.scheduleSave(); + } else if (!(await readerContext.restoreProgressState(entry, generation))) return false; + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + readerContext.consumeBookmarkHandoff(entry); + return true; + } catch (error) { + readerContext.reportNavigationError(error, generation); + return false; + } + } + + async function restoreInitialPosition(entry, generation) { + if (!entry || !readerContext.isReaderGenerationCurrent("navigation", generation)) return; + const restored = await readerContext.formatAdapters.active.restore(entry, generation); + if (!restored && readerContext.isReaderGenerationCurrent("navigation", generation)) + throw Object.assign(new Error("阅读位置恢复失败,原进度已保留,请重试加载。"), { + code: "READER_RESTORE" + }); + } + + async function applyInitialRestoration(entry, generation) { + readerContext.updateDocumentState({ restoredEntry: entry }); + if (entry?.zoom && !readerContext.zoomChangedByUser && readerContext.isReaderGenerationCurrent("navigation", generation)) + readerContext.setZoom(entry.zoom, false); + await readerContext.restoreInitialPosition(entry, generation); + } + + function updateReadingProgressSummary() { + const noToc = !readerContext.navigationState.tocEntries.length && !readerContext.mediaElement; + readerContext.readingProgressSummary.hidden = !noToc; + if (noToc) + readerContext.readingProgressSummary.querySelector(".reader-progress-summary-value").textContent = + `${readerContext.readerProgressPercent().toFixed(1)}%`; + } + + function updateProgressTools() { + if (readerContext.progressSeeking) return; + const percent = readerContext.readerProgressPercent(); + readerContext.progressRange.value = String(percent); + readerContext.progressPercent.textContent = `${percent.toFixed(1)}%`; + const hasChapters = readerContext.navigationState.tocEntries.some((entry) => !entry.group), + hasPreviousChapter = readerContext.adjacentTocIndex(-1) >= 0, + hasNextChapter = readerContext.adjacentTocIndex(1) >= 0; + readerContext.chapterPrev.hidden = !hasChapters; + readerContext.chapterNext.hidden = !hasChapters; + readerContext.chapterPrev.disabled = !hasPreviousChapter; + readerContext.chapterNext.disabled = !hasNextChapter; + readerContext.progressUndo.hidden = !readerContext.previousProgressState; + readerContext.updateReadingProgressSummary(); + readerContext.placeReadingProgress(); + } + + function captureProgressState() { + return readerContext.foliateContinuous + ? readerContext.captureFoliateBookmarkPosition() + : readerContext.capability.mode === "epub-chapters" + ? readerContext.captureChapterPosition() + : readerContext.mediaElement + ? { mediaTime: readerContext.mediaElement.currentTime } + : readerContext.htmlFrame && readerContext.htmlFrame.contentWindow + ? { htmlScrollTop: readerContext.htmlFrame.contentWindow.scrollY } + : { scrollTop: readerContext.viewport.scrollTop }; + } + + async function restoreProgressState(state, generation = readerContext.beginReaderNavigation()) { + if (!state || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + if (readerContext.foliateContinuous && Number.isInteger(state.foliateSection)) + return readerContext.restoreFoliateBookmarkPosition(state, generation); + if (readerContext.capability.mode === "epub-chapters" && Number.isInteger(state.chapterIndex)) + return readerContext.restoreChapterPosition(state, generation); + try { + if (readerContext.mediaElement && Number.isFinite(state.mediaTime)) { + const media = readerContext.mediaElement; + if (!media.readyState) + await new Promise((resolve, reject) => { + const finish = (error) => { + media.removeEventListener("loadedmetadata", loaded); + media.removeEventListener("error", failed); + untrack(); + if (error) reject(error); + else resolve(); + }; + const loaded = () => finish(), + failed = () => { + if (!media._readerSourceRetry) finish(new Error("Media metadata unavailable")); + }; + const untrack = readerContext.trackReaderResource(() => finish(readerContext.readerAbortError())); + media.addEventListener("loadedmetadata", loaded, { once: true }); + media.addEventListener("error", failed); + }); + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + media.currentTime = state.mediaTime; + } else if (readerContext.htmlFrame && readerContext.htmlFrame.contentWindow && Number.isFinite(state.htmlScrollTop)) + readerContext.htmlFrame.contentWindow.scrollTo(0, state.htmlScrollTop); + else if (Number.isFinite(state.scrollTop)) { + if (readerContext.capability.mode === "text") { + let measured = 0, batch = 0, started = performance.now(); + for (const block of readerContext.content.querySelectorAll(".reader-text-block")) { + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + block.style.contentVisibility = "visible"; + const height = block.getBoundingClientRect().height; + block.style.containIntrinsicBlockSize = `auto ${height}px`; + block.style.contentVisibility = "auto"; + measured += height; + if (measured > state.scrollTop + readerContext.viewport.clientHeight) break; + if (++batch >= 8 || performance.now() - started >= 8) { + await readerContext.waitForReader(); + batch = 0; + started = performance.now(); + } + } + } + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + readerContext.viewport.scrollTop = state.scrollTop; + } + readerContext.updateProgressTools(); + readerContext.scheduleSave(); + return true; + } catch (error) { + readerContext.reportNavigationError(error, generation); + return false; + } + } + + function flushEpubSeek() { + if (readerContext.epubSeekFrame) { + cancelAnimationFrame(readerContext.epubSeekFrame); + readerContext.epubSeekFrame = 0; + readerContext.epubSeekPromise = readerContext.seekFoliateProgress(readerContext.epubSeekTarget.percent, readerContext.epubSeekTarget.generation); + } + return readerContext.epubSeekPromise; + } + + function seekProgress(value, preserve = false) { + if (!preserve || !readerContext.previousProgressState) readerContext.previousProgressState = readerContext.captureProgressState(); + const generation = readerContext.beginReaderNavigation(), + percent = Math.max(0, Math.min(100, Number(value))); + if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return; + if (readerContext.mediaElement && readerContext.mediaElement.duration) + readerContext.mediaElement.currentTime = (readerContext.mediaElement.duration * percent) / 100; + else if (readerContext.foliateContinuous) { + readerContext.epubSeekTarget = { percent, generation }; + readerContext.epubSeekFrame = requestAnimationFrame(() => { + readerContext.epubSeekFrame = 0; + readerContext.epubSeekPromise = readerContext.seekFoliateProgress(percent, generation); + }); + } else if (readerContext.capability.mode === "epub-chapters" && readerContext.chapterManifestLoader?.count) { + const position = Math.min(0.999999, percent / 100) * readerContext.chapterManifestLoader.count; + readerContext.epubSeekPromise = (async () => { + const index = Math.floor(position) + 1; + const article = await readerContext.chapterManifestLoader(index); + if (!article?.isConnected || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false; + return readerContext.restoreChapterPosition({chapterIndex:index,chapterManifest:readerContext.chapterManifestUrl || readerContext.sourceUrl, + chapterOffset:article.getBoundingClientRect().height * (position - Math.floor(position))}, generation); + })().catch(error => { readerContext.reportNavigationError(error, generation); return false; }); + } else if (readerContext.htmlFrame && readerContext.htmlFrame.contentWindow) + readerContext.htmlFrame.contentWindow.scrollTo( + 0, + (Math.max( + 0, + readerContext.htmlFrame.contentDocument.documentElement.scrollHeight - readerContext.htmlFrame.contentWindow.innerHeight + ) * + percent) / + 100 + ); + else + readerContext.viewport.scrollTop = + (Math.max(0, readerContext.viewport.scrollHeight - readerContext.viewport.clientHeight) * percent) / 100; + readerContext.updateProgressTools(); + readerContext.scheduleSave(); + } + + async function activateChapter(index) { + if ( + index < 0 || + index >= readerContext.navigationState.tocEntries.length || + index === readerContext.navigationState.currentChapterIndex + ) + return; + try { + await readerContext.navigateTocEntry(index); + } finally { + readerContext.updateProgressTools(); + } + } + + function adjacentTocIndex(direction) { + for (let index = readerContext.navigationState.currentChapterIndex + direction; + index >= 0 && index < readerContext.navigationState.tocEntries.length; index += direction) { + if (!readerContext.navigationState.tocEntries[index].group) return index; + } + return -1; + } + + return { + scheduleSave, + saveProgress, + placeReadingProgress, + currentReaderUrl, + loadReaderRestoration, + getInitialReaderRestoration, + initialReaderPage, + readerProgressPercent, + pageAtMarker, + syncCurrentPageFromMarker, + scheduleMarkerSync, + restoreFormat, + restoreInitialPosition, + applyInitialRestoration, + updateReadingProgressSummary, + updateProgressTools, + captureProgressState, + restoreProgressState, + flushEpubSeek, + seekProgress, + activateChapter, + adjacentTocIndex + }; +} diff --git a/static/reader-scroll-anchor.js b/static/reader/ui/reader-scroll-anchor.js similarity index 100% rename from static/reader-scroll-anchor.js rename to static/reader/ui/reader-scroll-anchor.js diff --git a/static/reader/ui/reader-toc.mjs b/static/reader/ui/reader-toc.mjs new file mode 100644 index 0000000000000000000000000000000000000000..8e1f34d56bb6ef4e6e70801d7f0c654700cc634c --- /dev/null +++ b/static/reader/ui/reader-toc.mjs @@ -0,0 +1,286 @@ +export function createReaderToc(readerContext) { + function updateNavigationState(patch) { + return readerContext.readerRuntime.update("navigation", patch); + } + + function headingTocEntries(root) { + return [...root.querySelectorAll("h1,h2,h3,h4,h5,h6")] + .filter((heading) => heading.textContent.trim()) + .map((target) => ({ + label: target.textContent.trim(), + depth: Number(target.tagName.slice(1)) - 1, + target, + activate: () => target.scrollIntoView({ block: "start" }) + })); + } + + function createHeadingLocationCache(root, entries) { + let positions = null, targets = [], width = -1, extent = -1, zoom = -1, wasAnimating = false, live = false; + const invalidate = () => { positions = null; }; + const mutations = new MutationObserver(invalidate), resize = new ResizeObserver(invalidate); + mutations.observe(root, {subtree:true, childList:true, characterData:true, + attributes:true, attributeFilter:["style", "class", "hidden", "width", "height"]}); + resize.observe(root); + root.addEventListener("load", invalidate, true); + root.ownerDocument.fonts?.addEventListener("loadingdone", invalidate); + return { root, entries, + at(marker, scroll) { + if (mutations.takeRecords().length) invalidate(); + const nextWidth = root.clientWidth, nextExtent = root.scrollHeight, nextZoom = readerContext.documentState.zoom; + const animating = root.getAnimations?.({subtree:true}).some(animation => animation.playState === "running" || animation.pending) || false; + if (live || animating || wasAnimating || nextWidth !== width || nextExtent !== extent || nextZoom !== zoom || + targets.some(row => row.target !== row.entry.target || row.connected !== !!row.target?.isConnected)) invalidate(); + wasAnimating = animating; + if (!positions) { + targets = entries.map((entry, index) => ({entry, index, target:entry.target, connected:!!entry.target?.isConnected})); + const fixed = new WeakMap(); + const scrollDependent = target => { + const ancestors = []; + let value = false; + for (let node = target; node; node = node.parentElement) { + if (fixed.has(node)) { value = fixed.get(node); break; } + ancestors.push(node); + const position = root.ownerDocument.defaultView?.getComputedStyle(node).position; + if (position === "fixed" || position === "sticky") { value = true; break; } + if (node === root) break; + } + for (const node of ancestors) fixed.set(node, value); + return value; + }; + // Source CSS may pin headings while scrolling; keep their live geometry. + live = targets.some(row => row.connected && scrollDependent(row.target)); + positions = targets.filter(row => row.connected).map(row => ({index:row.index, + top:row.target.getBoundingClientRect().top + scroll - marker})).sort((a,b) => a.top-b.top || a.index-b.index); + width = root.clientWidth; extent = root.scrollHeight; zoom = nextZoom; + } + let low = 0, high = positions.length; + while (low < high) { + const middle = (low + high) >> 1; + if (positions[middle].top <= scroll) low = middle + 1; + else high = middle; + } + return positions[low ? low - 1 : 0]?.index ?? -1; + }, + dispose() { + mutations.disconnect(); resize.disconnect(); + root.removeEventListener("load", invalidate, true); + root.ownerDocument.fonts?.removeEventListener("loadingdone", invalidate); + positions = null; targets = []; + } + }; + } + + function syncHeadingLocation() { + const marker = readerContext.htmlFrame ? 8 : readerContext.viewport.getBoundingClientRect().top + 8; + const root = readerContext.htmlFrame?.contentDocument?.body || readerContext.content; + if (!["foliate", "epub-chapters"].includes(readerContext.capability.mode)) { + if (readerContext.headingLocationCache?.root !== root || readerContext.headingLocationCache?.entries !== readerContext.navigationState.tocEntries) { + readerContext.headingLocationCache?.dispose(); + readerContext.headingLocationCache = readerContext.createHeadingLocationCache(root, readerContext.navigationState.tocEntries); + } + const current = readerContext.headingLocationCache.at(marker, readerContext.htmlFrame ? readerContext.htmlFrame.contentWindow.scrollY : readerContext.viewport.scrollTop); + if (current >= 0 && current !== readerContext.navigationState.currentChapterIndex) { + readerContext.updateNavigationState({currentChapterIndex:current}); + readerContext.updateTocCurrentMark(); + } + return; + } + let current = -1, + currentPosition = -Infinity, + first = -1, + firstPosition = Infinity; + for (const [index, entry] of readerContext.navigationState.tocEntries.entries()) { + if (!entry.target?.isConnected) continue; + const position = entry.target.getBoundingClientRect().top; + if (position <= marker && (position > currentPosition || (position === currentPosition && index > current))) { + current = index; + currentPosition = position; + } + if (position > marker && position < firstPosition) { + first = index; + firstPosition = position; + } + } + if (current < 0) current = first; + if (current >= 0 && current !== readerContext.navigationState.currentChapterIndex) { + readerContext.updateNavigationState({ currentChapterIndex: current }); + readerContext.updateTocCurrentMark(); + } + } + + async function navigateTocEntry(index) { + if (index < 0 || index >= readerContext.navigationState.tocEntries.length) return; + if (readerContext.navigationState.tocEntries[index].group) return; + const generation = readerContext.beginReaderNavigation(), + entry = readerContext.navigationState.tocEntries[index]; + try { + const activated = readerContext.foliateContinuous + ? await readerContext.activateFoliateTocEntry(entry, generation) + : await entry.activate(generation); + if (activated === false || !readerContext.isReaderGenerationCurrent("navigation", generation)) return; + readerContext.updateNavigationState({ currentChapterIndex: index }); + readerContext.updateTocCurrentMark(); + readerContext.updateProgressTools(); + readerContext.setReaderPanelOpen(false, true); + } catch (error) { + readerContext.reportNavigationError(error, generation); + } + } + + function createTocRow(entry, index) { + const row = document.createElement("div"); + row.className = "panel-item toc-item"; + row.dataset.tocIndex = String(index); + row.style.setProperty("--toc-depth", String(entry.depth || 0)); + const link = document.createElement("div"); + link.className = "panel-item-main"; + link.textContent = entry.label; + link.title = entry.label; + if (entry.group) { + row.classList.add("toc-group"); + link.setAttribute("role", "heading"); + link.setAttribute("aria-level", String(Math.min(6, (entry.depth || 0) + 1))); + row.appendChild(link); + return row; + } + link.tabIndex = 0; + link.setAttribute("role", "link"); + const activate = (event) => { + const selection = getSelection(); + if (event?.type === "click" && selection?.toString() && selection.containsNode(link, true)) return; + readerContext.navigateTocEntry(index); + }; + link.addEventListener("click", activate); + link.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + activate(); + } + }); + row.appendChild(link); + return row; + } + + function renderVirtualToc() { + const state = readerContext.tocVirtualState; + if (!state) return; + const height = Math.max(320, state.panelView.clientHeight), + start = Math.max(0, Math.floor(state.panelView.scrollTop / readerContext.TOC_VIRTUAL_ROW_HEIGHT) - 12), + end = Math.min(state.entries.length, start + Math.ceil(height / readerContext.TOC_VIRTUAL_ROW_HEIGHT) + 24), + fragment = document.createDocumentFragment(); + state.list.replaceChildren(); + for (let index = start; index < end; index++) { + const row = readerContext.createTocRow(state.entries[index], index); + row.classList.add("toc-virtual-row"); + row.style.top = `${index * readerContext.TOC_VIRTUAL_ROW_HEIGHT}px`; + fragment.appendChild(row); + } + state.list.appendChild(fragment); + readerContext.updateTocCurrentMark(); + } + + function setToc(entries) { + const normalizedEntries = (entries || []).map((entry) => ({ + ...entry, + href: String(entry.href || ""), + label: readerContext.cleanTocLabel(entry.label) + })); + readerContext.updateNavigationState({ + tocEntries: normalizedEntries, + currentChapterIndex: normalizedEntries.findIndex((entry) => !entry.group) + }); + const tocTab = document.querySelector("#toc-tab"); + tocTab.hidden = false; + tocTab.textContent = normalizedEntries.length ? "目录" : "阅读"; + const previousList = document.querySelector("#toc-list"), + list = previousList.cloneNode(false); + readerContext.tocVirtualState?.destroy(); + readerContext.tocVirtualState = null; + previousList.replaceWith(list); + const virtualizationThreshold = readerContext.chapterManifestUrl ? 2000 : readerContext.TOC_VIRTUALIZATION_THRESHOLD; + if (normalizedEntries.length > virtualizationThreshold) { + const panelView = list.parentElement; + list.classList.add("toc-list-virtualized"); + list.style.height = `${normalizedEntries.length * readerContext.TOC_VIRTUAL_ROW_HEIGHT}px`; + let frame = 0; + const onScroll = () => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + readerContext.renderVirtualToc(); + }); + }; + panelView.addEventListener("scroll", onScroll, { passive: true }); + readerContext.tocVirtualState = { + entries: normalizedEntries, + list, + panelView, + render: readerContext.renderVirtualToc, + destroy: () => { + panelView.removeEventListener("scroll", onScroll); + if (frame) cancelAnimationFrame(frame); + } + }; + readerContext.renderVirtualToc(); + } else { + for (const [index, entry] of normalizedEntries.entries()) + list.appendChild(readerContext.createTocRow(entry, index)); + } + readerContext.updateTocCurrentMark(); + } + + function cleanTocLabel(value) { + return ( + String(value || "未命名章节") + .replace(/[\uFFFD\u2610-\u2612\u25a1\u0000-\u001F\u007F]/gu, "") + .replace(/\s{2,}/gu, " ") + .trim() || "未命名章节" + ); + } + + function updateTocCurrentMark() { + const rows = [...document.querySelectorAll("#toc-list .toc-item")]; + if (readerContext.documentState.pageCount > 1 && readerContext.documentState.page > 0) { + let pageIndex = -1; + for (const [index, entry] of readerContext.navigationState.tocEntries.entries()) { + const match = entry.label.match(/第\s*(\d+)\s*页/u); + if (match && Number(match[1]) <= readerContext.documentState.page) pageIndex = index; + } + if (pageIndex >= 0) readerContext.updateNavigationState({ currentChapterIndex: pageIndex }); + } + for (const row of rows) { + const index = Number(row.dataset.tocIndex); + let mark = row.querySelector(".toc-current-mark"); + if (!mark) { + mark = document.createElement("span"); + mark.className = "toc-current-mark"; + mark.textContent = "✓"; + row.appendChild(mark); + } + mark.hidden = index !== readerContext.navigationState.currentChapterIndex; + row.classList.toggle("is-current", index === readerContext.navigationState.currentChapterIndex); + } + } + + function syncTocTab() { + const tab = document.querySelector("#toc-tab"); + if (!tab) return; + tab.hidden = !readerContext.navigationState.tocEntries.length; + if (readerContext.navigationState.tocEntries.length && readerContext.navigationState.currentChapterIndex < 0) + readerContext.updateNavigationState({ currentChapterIndex: readerContext.navigationState.tocEntries.findIndex((entry) => !entry.group) }); + } + + return { + updateNavigationState, + headingTocEntries, + createHeadingLocationCache, + syncHeadingLocation, + navigateTocEntry, + createTocRow, + renderVirtualToc, + setToc, + cleanTocLabel, + updateTocCurrentMark, + syncTocTab + }; +} diff --git a/static/reader/ui/reader-ui.mjs b/static/reader/ui/reader-ui.mjs new file mode 100644 index 0000000000000000000000000000000000000000..ef913d2a1c14d661d4f4b2fb954a61e00acb2090 --- /dev/null +++ b/static/reader/ui/reader-ui.mjs @@ -0,0 +1,256 @@ +export function createReaderUi(readerContext) { + function showDownloadFeedback(message, duration = 4000) { + clearTimeout(readerContext.downloadFeedbackTimer); + readerContext.downloadFeedback.textContent = message; + readerContext.downloadFeedback.hidden = !message; + if (message && duration) readerContext.downloadFeedbackTimer = setTimeout(() => readerContext.showDownloadFeedback(""), duration); + } + + function updateDocumentState(patch) { + return readerContext.readerRuntime.update("document", patch); + } + + function updatePanelState(patch) { + return readerContext.readerRuntime.update("panel", patch); + } + + function applyPageInversion(persist = false) { + document.documentElement.dataset.pageInverted = String(readerContext.pageInverted); + readerContext.pageInvertToggle.textContent = readerContext.pageInverted ? "关闭页面反色" : "页面反色"; + readerContext.pageInvertToggle.title = readerContext.pageInvertToggle.textContent; + readerContext.pageInvertToggle.setAttribute("aria-label", readerContext.pageInvertToggle.textContent); + readerContext.pageInvertToggle.setAttribute("aria-pressed", String(readerContext.pageInverted)); + if (persist) { + try { localStorage.setItem("reader-page-inversion", String(readerContext.pageInverted)); } catch (_) {} + } + } + + function applyReaderTheme(theme, persist = true, animate = true) { + const anchor = readerContext.foliateScrollAnchors.capture(); + if (animate) { + clearTimeout(readerContext.themeAnimationTimer); + document.documentElement.classList.add("theme-transition"); + void document.documentElement.offsetWidth; + } + readerContext.readerTheme = theme === "light" ? "light" : "dark"; + document.documentElement.dataset.theme = readerContext.readerTheme; + readerContext.readerThemeToggle.innerHTML = readerContext.readerTheme === "dark" ? readerContext.THEME_MOON_ICON : readerContext.THEME_SUN_ICON; + if (animate) { + readerContext.readerThemeToggle.classList.remove("is-changing"); + void readerContext.readerThemeToggle.offsetWidth; + readerContext.readerThemeToggle.classList.add("is-changing"); + readerContext.themeAnimationTimer = setTimeout(() => { + document.documentElement.classList.remove("theme-transition"); + readerContext.readerThemeToggle.classList.remove("is-changing"); + }, 280); + } + readerContext.readerThemeToggle.title = readerContext.readerTheme === "dark" ? "切换到白天模式" : "切换到夜间模式"; + readerContext.readerThemeToggle.setAttribute("aria-label", readerContext.readerThemeToggle.title); + readerContext.readerThemeToggle.setAttribute("aria-pressed", String(readerContext.readerTheme === "light")); + const docxBody = readerContext.content.querySelector(".docx-body"); + if (docxBody) docxBody.classList.toggle("reader-document-dark", readerContext.readerTheme === "dark"); + for (const article of readerContext.content.querySelectorAll(".foliate-continuous > article")) + article.classList.toggle("reader-document-dark", readerContext.readerTheme === "dark"); + if (persist) localStorage.setItem("theme", readerContext.readerTheme); + readerContext.foliateScrollAnchors.restore(anchor); + } + + function setZoom(percent, persist = true) { + const anchor = readerContext.foliateScrollAnchors.capture(); + const pdfAnchor = readerContext.pdfHybridUrl ? readerContext.pageAtMarker() : null; + const pdfBounds = pdfAnchor?.getBoundingClientRect(); + const pdfOffset = pdfBounds ? (readerContext.viewport.getBoundingClientRect().top - pdfBounds.top) / Math.max(1, pdfBounds.height) : 0; + const normalized = VoiceOfMLReader.clampNumber(percent, 25, 400, 100); + const horizontalCenter = readerContext.viewport.scrollWidth + ? (readerContext.viewport.scrollLeft + readerContext.viewport.clientWidth / 2) / readerContext.viewport.scrollWidth + : 0; + readerContext.updateDocumentState({ zoom: normalized / 100 }); + readerContext.content.style.setProperty("--reader-zoom", String(readerContext.documentState.zoom)); + readerContext.zoomInput.value = String(normalized); + readerContext.pdfShellWindow?.resize(); + if (pdfAnchor?.isConnected) { + const bounds = pdfAnchor.getBoundingClientRect(); + readerContext.viewport.scrollTop += bounds.top - readerContext.viewport.getBoundingClientRect().top + pdfOffset * bounds.height; + } + if (readerContext.htmlFrame && readerContext.htmlFrame.contentDocument) + readerContext.htmlFrame.contentDocument.documentElement.style.zoom = String(readerContext.documentState.zoom); + if (readerContext.pdfDocument) readerContext.rerenderVisiblePdfPages(); + if (readerContext.capability.mode === "foliate") + readerContext.content.style.setProperty("--reader-zoom", String(readerContext.documentState.zoom)); + readerContext.viewport.scrollLeft = horizontalCenter * readerContext.viewport.scrollWidth - readerContext.viewport.clientWidth / 2; + if (persist) { + readerContext.zoomChangedByUser = true; + localStorage.setItem("reader-zoom", String(normalized)); + } + readerContext.foliateScrollAnchors.restore(anchor); + if (persist) readerContext.scheduleSave(); + } + + function emptyPanel(list, message) { + list.innerHTML = `
${message}
`; + } + + function highlightPanelItem(item, query) { + const nodes = []; + const walker = document.createTreeWalker(item, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) + if (!walker.currentNode.parentElement.closest(".panel-item-remove")) + nodes.push(walker.currentNode); + const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "giu"); + for (const node of nodes) { + const text = node.data, + matches = [...text.matchAll(pattern)]; + if (!matches.length) continue; + let start = 0; + const fragment = document.createDocumentFragment(); + for (const match of matches) { + fragment.append(text.slice(start, match.index)); + const mark = document.createElement("mark"); + mark.className = "search-match"; + mark.textContent = match[0]; + fragment.append(mark); + start = match.index + match[0].length; + } + fragment.append(text.slice(start)); + node.replaceWith(fragment); + } + } + + function filterPanel(view) { + readerContext.clearSearchHighlights(view); + const query = (view.querySelector(".panel-search").value || "").trim().toLowerCase(); + for (const item of view.querySelectorAll(".panel-item")) { + const searchableText = [...item.children] + .filter((child) => !child.classList.contains("panel-item-remove")) + .map((child) => child.textContent) + .join(" ") + .toLowerCase(); + item.hidden = !!query && !searchableText.includes(query); + if (query && !item.hidden) readerContext.highlightPanelItem(item, query); + } + } + + function notifyReaderUiState(state) { + readerContext.readerUiHistoryState = state; + if (window.parent !== window) + window.parent.postMessage({ type: "voice-reader-ui-state", state }, location.origin); + } + + function setReaderPanelOpen(open, restoreFocus = false) { + readerContext.readerRuntime.update("panel", { open }); + const panel = document.querySelector("#history-panel"); + clearTimeout(readerContext.panelAnimationTimer); + document.querySelector("#history").setAttribute("aria-expanded", String(open)); + if (open) { + readerContext.readerPanelInteractionSerial++; + panel.hidden = false; + void panel.offsetWidth; + panel.classList.add("is-open"); + readerContext.selectPanel(readerContext.panelState.selected === "full-search" ? "full-search" : readerContext.mediaElement ? "media" : "toc"); + readerContext.updateTocCurrentMark(); + readerContext.notifyReaderUiState(readerContext.panelState.selected === "full-search" ? "full-search" : "panel"); + return; + } + panel.classList.remove("is-open"); + readerContext.panelAnimationTimer = setTimeout(() => { + if (!panel.classList.contains("is-open")) panel.hidden = true; + }, 250); + readerContext.notifyReaderUiState("base"); + if (restoreFocus) document.querySelector("#history").focus({ preventScroll: true }); + } + + function setPanelSearchOpen(button, open) { + const input = button.closest(".reader-panel-view").querySelector(".panel-search"); + button.setAttribute("aria-expanded", String(open)); + if (open) { + input.hidden = false; + void input.offsetWidth; + input.classList.add("is-open"); + input.focus(); + return; + } + input.classList.remove("is-open"); + setTimeout(() => { + if (!input.classList.contains("is-open")) input.hidden = true; + }, 190); + button.focus(); + } + + function selectPanel(name) { + if (name === "media" && readerContext.mediaTab.hidden) name = "bookmarks"; + readerContext.updatePanelState({ selected: name }); + if (readerContext.progressTools) readerContext.progressTools.hidden = !["toc", "media"].includes(name); + for (const button of document.querySelectorAll(".reader-panel-tabs button")) { + const selected = button.dataset.panel === name; + button.setAttribute("aria-selected", String(selected)); + button.tabIndex = selected ? 0 : -1; + } + for (const view of document.querySelectorAll(".reader-panel-view")) + view.hidden = view.dataset.panelView !== name; + if (name === "full-search") readerContext.notifyReaderUiState("full-search"); + else if (readerContext.panelState.open) readerContext.notifyReaderUiState("panel"); + if (name === "bookmarks") readerContext.renderBookmarks(); + if (name === "history") readerContext.renderHistory(); + if (name === "media" && readerContext.mediaElement) + readerContext.mediaPanel.querySelector(".media-panel-time").textContent = + `${readerContext.formatMediaTime(readerContext.mediaElement.currentTime)} / ${readerContext.formatMediaTime(readerContext.mediaElement.duration)}`; + readerContext.updateProgressTools(); + } + + async function renderHistory() { + const list = document.querySelector("#history-list"), + generation = readerContext.nextReaderGeneration("history"); + try { + const entries = await VoiceOfMLReaderStore.list(); + if (!readerContext.isReaderGenerationCurrent("history", generation)) return; + list.textContent = ""; + for (const entry of entries) { + const row = document.createElement("div"); + row.className = "panel-item"; + if (entry.mediaTime != null) row.dataset.mediaTime = String(entry.mediaTime); + const link = document.createElement("button"); + link.type = "button"; + link.className = "panel-item-main"; + link.textContent = entry.title || entry.url; + link.addEventListener("click", () => readerContext.navigateReader(entry.readerUrl)); + const meta = document.createElement("small"); + meta.textContent = `${entry.pageCount ? `第 ${entry.page || 1} / ${entry.pageCount} 页 · ` : ""}${new Date(entry.lastReadAt).toLocaleString()}`; + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "panel-item-remove"; + remove.textContent = "删除"; + remove.addEventListener("click", async () => { + if (entry.url === readerContext.sourceUrl) { + readerContext.historySuppressed = true; + await readerContext.progressSaveChain; + } + await VoiceOfMLReaderStore.remove(entry.url); + readerContext.renderHistory(); + }); + row.append(link, remove, meta); + list.appendChild(row); + } + if (!list.childElementCount) readerContext.emptyPanel(list, "暂无阅读记录"); + readerContext.filterPanel(document.querySelector("#history-view")); + } catch (_) { + if (readerContext.isReaderGenerationCurrent("history", generation)) readerContext.emptyPanel(list, "无法读取本地记录"); + } + } + + return { + showDownloadFeedback, + updateDocumentState, + updatePanelState, + applyPageInversion, + applyReaderTheme, + setZoom, + emptyPanel, + highlightPanelItem, + filterPanel, + notifyReaderUiState, + setReaderPanelOpen, + setPanelSearchOpen, + selectPanel, + renderHistory + }; +} diff --git a/static/download-controller.js b/static/search/download-controller.js similarity index 100% rename from static/download-controller.js rename to static/search/download-controller.js diff --git a/static/reader-navigation.js b/static/search/reader-navigation.js similarity index 100% rename from static/reader-navigation.js rename to static/search/reader-navigation.js diff --git a/static/search-session.js b/static/search/search-session.js similarity index 100% rename from static/search-session.js rename to static/search/search-session.js diff --git a/static/sw.js b/static/sw.js index bb5fd3583be6e3661f8e33cb6dbcaca7444d9be5..9a890bcb09a6f4d71004f9307ba636475a92c783 100644 --- a/static/sw.js +++ b/static/sw.js @@ -4,7 +4,7 @@ const CURRENT_HASHED_ASSETS = []; // Filled by the static build. let cachePruning = Promise.resolve(); function isRetiredAsset(pathname) { return CURRENT_HASHED_ASSETS.length > 0 && - (/\.[0-9a-f]{12}\.(js|mjs|css)$/.test(pathname) || /^\/static\/[^/]+\.(js|mjs|css)$/.test(pathname)) && + (/\.[0-9a-f]{12}\.(js|mjs|css)$/.test(pathname) || /^\/static\/(?:(?:reader|search)\/[^?]+|[^/]+)\.(js|mjs|css)$/.test(pathname)) && !CURRENT_HASHED_ASSETS.includes(pathname); } function pruneHashedAssets(cache, resetShell = false) { @@ -21,8 +21,8 @@ const PRECACHE_URLS = [ "/", "/static/style.css", "/static/reader-resources.js", - "/static/reader-contract.js", - "/static/reader-navigation.js", + "/static/reader/core/reader-contract.js", + "/static/search/reader-navigation.js", "/static/app.js", "/data/initial/manifest.json", "/data/sidebar/manifest.json", diff --git a/tests/reader_source_contract.js b/tests/reader_source_contract.js index 57493caf7aa3b625c4d2422476dcf182c7c803b5..18299678a4e478166fb2edaba510938e42ca26da 100644 --- a/tests/reader_source_contract.js +++ b/tests/reader_source_contract.js @@ -1,4 +1,13 @@ const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +function readReaderSource(root) { + const resources = require(path.join(root, "static/reader-resources.js")); + return ["reader.js", ...resources.readerComponentFiles].map(file => + fs.readFileSync(path.join(root, "static", file), "utf8") + .replace(/\breaderContext\./g, "").replace(/^ /gm, "")).join("\n"); +} // Match code tokens across layout changes, preserving quoted values and operators. // This is a snippet matcher, not a JavaScript parser or a source minifier. @@ -14,4 +23,4 @@ function assertCode(source, snippet) { assert.match(source, codePattern(snippet), snippet); } -module.exports = { codePattern, assertCode }; +module.exports = { codePattern, assertCode, readReaderSource }; diff --git a/tests/reader_sources.py b/tests/reader_sources.py new file mode 100644 index 0000000000000000000000000000000000000000..269d8cf3f3217a66a643341b7629b937208dfa1f --- /dev/null +++ b/tests/reader_sources.py @@ -0,0 +1,16 @@ +"""Read the actual component graph for non-executing source smoke assertions.""" +import re + + +def read_reader_source(root): + static = root / "static" + barrel = static / "reader/reader-components.mjs" + exports = barrel.read_text(encoding="utf-8") + files = [static / "reader.js", *(barrel.parent / name for name in + re.findall(r'from "\./([\w/-]+\.mjs)"', exports))] + sources = [] + for filename in files: + source = filename.read_text(encoding="utf-8") + source = re.sub(r"\breaderContext\.", "", source) + sources.append(re.sub(r"^ ", "", source, flags=re.M)) + return "\n".join(sources) diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index bcdb04d5bd74ea64eb23511f735861d91dd3d351..0ea3ed7d99693131e0f834caa97d51710089d5f0 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -8,7 +8,7 @@ from unittest import mock IMPORT_ERROR = None try: - import app + from backend import app except ModuleNotFoundError as exc: # pragma: no cover app = None IMPORT_ERROR = exc diff --git a/tests/test_async_proxy.py b/tests/test_async_proxy.py index 8f5c7a9da8365e0a5efec8b5e8fa396fbf01e853..a46ed06dbd6c91cfe93d24f746a7fd852593998b 100644 --- a/tests/test_async_proxy.py +++ b/tests/test_async_proxy.py @@ -16,8 +16,8 @@ from unittest.mock import AsyncMock, Mock, patch IMPORT_ERROR = None try: import aiohttp - import app - import upstream_http + from backend import app + from backend import upstream_http except ModuleNotFoundError as exc: # pragma: no cover app = None IMPORT_ERROR = exc diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index 09c5896e3c4e85500e35f4d639210186f5a6d925..56ba00695d67cfd6e405532ae184e9f2aa279c82 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -14,7 +14,7 @@ from tests.real_server import RealUvicornServer IMPORT_ERROR = None try: - import app + from backend import app except ModuleNotFoundError as exc: app = None IMPORT_ERROR = exc diff --git a/tests/test_cache_and_malformed.py b/tests/test_cache_and_malformed.py index 078f19680c1ee29f795e3242a537eb92ae7c7aec..176d6d3051d854a1487e6dfd49d238ad42d21678 100644 --- a/tests/test_cache_and_malformed.py +++ b/tests/test_cache_and_malformed.py @@ -9,7 +9,7 @@ from pathlib import Path IMPORT_ERROR = None try: - import app + from backend import app except ModuleNotFoundError as exc: # pragma: no cover app = None IMPORT_ERROR = exc @@ -165,7 +165,7 @@ class CacheAndMalformedTests(unittest.TestCase): self.assertEqual(app.load_ngram_index(path, 2, 0, digest), ({}, b"")) def test_posting_loaders_preserve_ids_and_reject_malformed_entries(self): - from search_indexes import WORD_INDEX_TOKENIZER, resolve_postings + from backend.search_indexes import WORD_INDEX_TOKENIZER, resolve_postings digest = b"x" * 32 tokenizer = WORD_INDEX_TOKENIZER.encode() @@ -194,7 +194,7 @@ class CacheAndMalformedTests(unittest.TestCase): self.assertEqual(actual, {"a": {0, 2}} if entries == valid else {}) def test_nonmaterialized_posting_validation_matches_set_decoder(self): - from search_indexes import _validate_postings, decode_postings + from backend.search_indexes import _validate_postings, decode_postings # Zero deltas may repeat an ID; a continuation must end within this posting. cases = [b"", b"\x00\x00", b"\x01\x00\x02", b"\x80\x00", diff --git a/tests/test_data_decoding.py b/tests/test_data_decoding.py index ace90242f5cd00583966487d347686acfbcce1e9..388d27c62b3466c4e90b76a316781d4d4d44504e 100644 --- a/tests/test_data_decoding.py +++ b/tests/test_data_decoding.py @@ -4,7 +4,7 @@ import unittest IMPORT_ERROR = None try: - import app + from backend import app except ModuleNotFoundError as exc: # pragma: no cover - environment dependent app = None IMPORT_ERROR = exc diff --git a/tests/test_fastapi_endpoints.py b/tests/test_fastapi_endpoints.py index 995fbae3ea938467b9ef5e493aa6b0d8968e43d6..afa4f614b0b8d0eded92af270cbc9261d1822d30 100644 --- a/tests/test_fastapi_endpoints.py +++ b/tests/test_fastapi_endpoints.py @@ -11,7 +11,7 @@ from unittest import mock IMPORT_ERROR = None try: - import app + from backend import app from fastapi.testclient import TestClient except ModuleNotFoundError as exc: # pragma: no cover app = None @@ -508,7 +508,7 @@ class FastApiEndpointTests(unittest.TestCase): self.assertTrue(result["fallback"].endswith(fallback)) def test_reader_static_assets_are_served(self): - for path in ("/static/reader.html", "/static/reader.js", "/static/reader.css", "/static/reader-contract.js"): + for path in ("/static/reader.html", "/static/reader.js", "/static/reader.css", "/static/reader/core/reader-contract.js"): self.assertEqual(self.client.get(path).status_code, 200) def test_unused_reader_metadata_alias_is_retired(self): diff --git a/tests/test_native_search_indexes.py b/tests/test_native_search_indexes.py index 98e0f6a8c431a2cdb360a374c2a1988c2a8e9c9f..429007b2be18cc2392fc45bff1c33aea58a72b32 100644 --- a/tests/test_native_search_indexes.py +++ b/tests/test_native_search_indexes.py @@ -3,9 +3,7 @@ import random import unittest from unittest.mock import patch -import search_indexes - - +from backend import search_indexes def varint(value): encoded = bytearray() while value >= 128: diff --git a/tests/test_proxy_ownership.py b/tests/test_proxy_ownership.py index 728201bb2dbd70b2a5650db3a80b276010a10812..7a21309b1bbfe49f2a00a4c4c3ab0c0eccd9fed5 100644 --- a/tests/test_proxy_ownership.py +++ b/tests/test_proxy_ownership.py @@ -5,7 +5,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch -import app +from backend import app from tests.test_async_proxy import FakeResponse, FakeSession diff --git a/tests/test_reader_accessibility.py b/tests/test_reader_accessibility.py index 4112aa938724b12ea85bc724f8fd30a9c831f65a..afad4c652c76e0da21f008856aad53b634030d0c 100644 --- a/tests/test_reader_accessibility.py +++ b/tests/test_reader_accessibility.py @@ -1,13 +1,14 @@ import unittest from tests.test_static_contract import assert_source_contains from pathlib import Path +from tests.reader_sources import read_reader_source ROOT = Path(__file__).parents[1] class ReaderAccessibilityTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.reader = (ROOT / "static/reader.js").read_text(encoding="utf-8") + cls.reader = read_reader_source(ROOT) cls.html = (ROOT / "static/reader.html").read_text(encoding="utf-8") cls.css = (ROOT / "static/reader.css").read_text(encoding="utf-8") diff --git a/tests/test_reader_architecture.js b/tests/test_reader_architecture.js index 6f979adce95bda55304a879465e0215d6c873afd..ef3146889222cec85f449ed4fb3e9d7ba64b962b 100644 --- a/tests/test_reader_architecture.js +++ b/tests/test_reader_architecture.js @@ -1,18 +1,21 @@ const fs = require('fs') const path = require('path') const assert = require('assert') -const { assertCode, codePattern } = require('./reader_source_contract') +const { assertCode, codePattern, readReaderSource } = require('./reader_source_contract') const root = path.resolve(__dirname, '..') -const reader = fs.readFileSync(path.join(root, 'static/reader.js'), 'utf8') -const searchController = fs.readFileSync(path.join(root, 'static/reader-search-controller.mjs'), 'utf8') -assertCode(reader, 'import { createReaderSearchController } from "./reader-search-controller.mjs"') +const readerEntry = fs.readFileSync(path.join(root, 'static/reader.js'), 'utf8') +const reader = readReaderSource(root) +assert.doesNotMatch(readerEntry, /^\s*(?:async\s+)?function\s+/m) +assertCode(readerEntry, 'from "./reader/reader-components.mjs"') +const searchController = fs.readFileSync(path.join(root, 'static/reader/search/reader-search-controller.mjs'), 'utf8') +assertCode(reader, 'import { createReaderSearchController } from "./reader/search/reader-search-controller.mjs"') assertCode(reader, 'trackReaderResource(fullSearchController.dispose)') assertCode(reader, 'backend: { mode: () => capability.mode, search: searchReaderText') assert.doesNotMatch(reader, /dataset\.panelView\s*=\s*"full-search"/) assert.doesNotMatch(searchController, /new Worker|\bfetch\(|import\(/) -const domSearch = fs.readFileSync(path.join(root, 'static/reader-dom-search.mjs'), 'utf8') -assertCode(reader, 'import { createReaderDomSearch, fullSearchEscape, fullSearchSnippet, fullSearchPageOffset } from "./reader-dom-search.mjs"') +const domSearch = fs.readFileSync(path.join(root, 'static/reader/search/reader-dom-search.mjs'), 'utf8') +assertCode(reader, 'import { createReaderDomSearch, fullSearchEscape, fullSearchSnippet, fullSearchPageOffset } from "./reader/search/reader-dom-search.mjs"') assertCode(reader, 'trackReaderResource(domSearch.dispose)') assertCode(reader, 'getTocEntries: () => navigationState.tocEntries') assertCode(reader, 'domSearch.pruneMarks()') @@ -21,6 +24,12 @@ assert.doesNotMatch(domSearch, /new Worker|\bfetch\(|import\(|readerRuntime|navi assertCode(domSearch, 'const stopGeneration = runtime.events.on("generation"') assertCode(domSearch, 'function dispose() { stopGeneration()') assertCode(domSearch, 'publishPage(page(0), page, true)') +const imageLoader = fs.readFileSync(path.join(root, 'static/reader/formats/reader-image-loader.mjs'), 'utf8') +assertCode(reader, 'import { createReaderImageLoader } from "./reader/formats/reader-image-loader.mjs"') +assertCode(reader, 'trackResource: trackReaderResource') +assertCode(reader, 'await imageLoader.load(image, target, renderController.signal)') +assert.doesNotMatch(reader, /function (?:waitReaderImage|loadReaderImage)\(/) +assert.doesNotMatch(imageLoader, /readerRuntime|readerContentUrl|pdfPageManifest|documentState|new Image|import\(/) const css = fs.readFileSync(path.join(root, 'static/reader.css'), 'utf8') const view = fs.readFileSync(path.join(root, 'static/foliate-reader/view.js'), 'utf8') 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) @@ -80,7 +89,7 @@ assert.match(reader, /function schedulePdfManifestPrefetch\(page\)/) assert.match(reader, /image\.fetchPriority = "low"/) assert.match(reader, /pdfManifestPrefetchActive\.add\(record\)/) assert.match(reader, /queuePdfManifestPrefetch\(\)/) -const pdfPageRender = reader.match(/function renderPdfManifestShell\(shell, force = false, priority = false\) \{[\s\S]*?\n\}\nfunction renderPdfShell/)[0] +const pdfPageRender = reader.match(/function renderPdfManifestShell\(shell, force = false, priority = false\) \{[\s\S]*?\n\}\s+function renderPdfShell/)[0] assert.match(pdfPageRender, /(?:const|let) target = pdfPageManifest\.pageUrl\(entry\.page\)/) assert.doesNotMatch(pdfPageRender, /entry\.j|entry\.w|\.jxl/) assert.match(reader, /chapterManifestObserver\?\.disconnect\(\)/) @@ -172,7 +181,7 @@ assert.match(css, /min-height:\s*0\s*!important;\s*height:\s*auto\s*!important;/ assert.strictEqual(view.includes('.find(x => x.index = resolved.index)'), false) assert.strictEqual(view.includes('.find(x => x.index === resolved.index)'), true) const vm = require('vm') -const priorityFunctions = reader.match(/function pdfPagePriority\(shell\) \{[\s\S]*?\n\}\nfunction normalizePdfPriority\(priority\) \{[\s\S]*?\n\}/) +const priorityFunctions = reader.match(/function pdfPagePriority\(shell\) \{[\s\S]*?\n\}\s+function normalizePdfPriority\(priority\) \{[\s\S]*?\n\}/) assert.ok(priorityFunctions, 'PDF page priority functions') const priorityContext = { viewport: { getBoundingClientRect: () => ({ top: 0, bottom: 100 }) }, PDF_PREFETCH_MARGIN: 2400 } vm.runInNewContext(priorityFunctions[0], priorityContext) @@ -181,8 +190,8 @@ for (const [top, bottom, expected] of [[10, 90, 3], [70, 160, 2], [110, 190, 1], assert.strictEqual(priorityContext.normalizePdfPriority(true), 3) const sandbox = { self: {} }; sandbox.self = sandbox; function loadExports(file, name) { vm.runInNewContext(fs.readFileSync(path.join(root, 'static', file), 'utf8'), sandbox); return sandbox[name]; } -const contractFeatures = loadExports('reader-contract.js', 'VoiceOfMLReader').features -const runtimeMatrix = loadExports('reader-runtime.js', 'VoiceOfMLReaderRuntime').FEATURE_MATRIX +const contractFeatures = loadExports('reader/core/reader-contract.js', 'VoiceOfMLReader').features +const runtimeMatrix = loadExports('reader/core/reader-runtime.js', 'VoiceOfMLReaderRuntime').FEATURE_MATRIX assert.strictEqual(runtimeMatrix, contractFeatures) for (const mode of Object.keys(contractFeatures)) assert.strictEqual(JSON.stringify(runtimeMatrix[mode]), JSON.stringify(contractFeatures[mode]), mode) assert.strictEqual(JSON.stringify(runtimeMatrix.unsupported), JSON.stringify({ toc: false, search: false, zoom: false, bookmarks: false, pagination: false, media: false })) @@ -225,7 +234,7 @@ for (const [mode, adapter] of registered) { assert.deepStrictEqual(calls.splice(0), [['restoreFormat', mode, entry, 17], ['disposeFormatResources', mode]]) } const html = fs.readFileSync(path.join(root, 'static/reader.html'), 'utf8') -const securityScript = html.indexOf('src="/static/reader-security.js"') -assert.ok(securityScript >= 0 && securityScript < html.indexOf('src="/static/reader-contract.js"')) +const securityScript = html.indexOf('src="/static/reader/core/reader-security.js"') +assert.ok(securityScript >= 0 && securityScript < html.indexOf('src="/static/reader/core/reader-contract.js"')) assert.match(reader, /readerRuntime\.track\(window\.__VOICE_PDF_PRELOAD__\)/) console.log('reader architecture contracts passed') diff --git a/tests/test_reader_backend.py b/tests/test_reader_backend.py index 991d62c986145df57e710dcc1bfa3a9c0619ad49..22a2c95d113745b312e0f5a6c242b67d3c0f4af0 100644 --- a/tests/test_reader_backend.py +++ b/tests/test_reader_backend.py @@ -19,7 +19,7 @@ from urllib.parse import quote IMPORT_ERROR = None try: - import app + from backend import app import httpx except ModuleNotFoundError as exc: app = None @@ -238,7 +238,7 @@ class ReaderBackendTests(unittest.IsolatedAsyncioTestCase): self.stack.enter_context(patch.object(app, "records", [self.record])) self.stack.enter_context(patch.object(app, "reader_record_key_indices", {app.build_reader_asset_key(self.record): 0})) self.stack.enter_context(patch.object(app, "reader_source_id_indices", {"0ym7e7wbcxau0": [0]})) - self.stack.enter_context(patch("upstream_http.upstream_redirect_cache", {})) + self.stack.enter_context(patch("backend.upstream_http.upstream_redirect_cache", {})) async def asyncTearDown(self): await app.shutdown_reader_archive_state(app.app) diff --git a/tests/test_reader_book_text.mjs b/tests/test_reader_book_text.mjs index 246e7b4abb90a776ee28bbe73d7292c6651b0b31..e0a4b26b4999972850df59262416c0e3a8df0cd5 100644 --- a/tests/test_reader_book_text.mjs +++ b/tests/test_reader_book_text.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { hitBoxes, searchBookText } from "../static/reader-book-text.js"; +import { hitBoxes, searchBookText } from "../static/reader/search/reader-book-text.js"; const spans = [ { start: 0, end: 1, box: [0, 0, 0.2, 1], block: 0 }, diff --git a/tests/test_reader_chapter_preload.js b/tests/test_reader_chapter_preload.js index 88ec506b0f71ddf09fc0409b3bd9f55d53ea0ec7..dbe970e795456a177a587ca69dcfdca8456f0092 100644 --- a/tests/test_reader_chapter_preload.js +++ b/tests/test_reader_chapter_preload.js @@ -3,8 +3,8 @@ const fs = require('fs'); const path = require('path'); const vm = require('vm'); const root = process.argv[2] ? path.resolve(process.argv[2]) : path.resolve(__dirname, '..'); -const contract = fs.readFileSync(path.join(root, 'static/reader-contract.js'), 'utf8'); -const security = fs.readFileSync(path.join(root, 'static/reader-security.js'), 'utf8'); +const contract = fs.readFileSync(path.join(root, 'static/reader/core/reader-contract.js'), 'utf8'); +const security = fs.readFileSync(path.join(root, 'static/reader/core/reader-security.js'), 'utf8'); const source = 'https://voiceofml-search.hf.space/api/reader-bucket-resource?path=' + encodeURIComponent( 'chapters/ebook/epub/' + 'a'.repeat(64) + '/' + 'b'.repeat(16) + '/chapter-manifest.json'); diff --git a/tests/test_reader_chapter_repository.js b/tests/test_reader_chapter_repository.js index b1f3bb99527059ed57958efb39717b4c67c6c024..ca5e187a9104ecc9b92f633a5ef46d60eaebf600 100644 --- a/tests/test_reader_chapter_repository.js +++ b/tests/test_reader_chapter_repository.js @@ -6,7 +6,7 @@ const vm = require("vm"); function createRepository(options) { const sandbox = { self: {}, DOMException }; sandbox.self = sandbox; - vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader-chapter-repository.js"), "utf8"), sandbox); + vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader/formats/reader-chapter-repository.js"), "utf8"), sandbox); return sandbox.VoiceOfMLReaderChapters.createChapterRepository(options); } diff --git a/tests/test_reader_chapter_scheduler.js b/tests/test_reader_chapter_scheduler.js index b18204e81df7c4ae613a424427a0f18120a42b16..fc777f1dcc4fbeab46993a1f13872bbd39943dea 100644 --- a/tests/test_reader_chapter_scheduler.js +++ b/tests/test_reader_chapter_scheduler.js @@ -6,7 +6,7 @@ const vm = require("vm"); async function main() { const sandbox = { DOMException, AbortController }; sandbox.self = sandbox; - vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader-chapter-repository.js"), "utf8"), sandbox); + vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader/formats/reader-chapter-repository.js"), "utf8"), sandbox); const starts = [], held = new Map(); const scheduler = sandbox.VoiceOfMLReaderChapters.createChapterScheduler({ create(index, signal, priority) { diff --git a/tests/test_reader_chapter_search.mjs b/tests/test_reader_chapter_search.mjs index 1a4054321ce6f6ae58f17fd48450d0bd919594a7..a7b83b47d28833b722b52d3e0d98e11b65bccd2b 100644 --- a/tests/test_reader_chapter_search.mjs +++ b/tests/test_reader_chapter_search.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { countMatches, isSearchIndexProxyURL, resultPage, scanText, validateIndex } from "../static/reader-chapter-search-worker.mjs"; +import { countMatches, isSearchIndexProxyURL, resultPage, scanText, validateIndex } from "../static/reader/search/workers/reader-chapter-search-worker.mjs"; const origin = "https://voiceofml-search.hf.space"; const indexURL = path => new URL(origin + "/api/reader-bucket-resource?path=" + encodeURIComponent(path)); diff --git a/tests/test_reader_close_traffic.py b/tests/test_reader_close_traffic.py index af13f01aba5af5f4927afcf14eb76da20b3e4f2c..96517c5a68d2991e750a6ede4abe33186e856317 100644 --- a/tests/test_reader_close_traffic.py +++ b/tests/test_reader_close_traffic.py @@ -72,7 +72,7 @@ class ReaderCloseTrafficTests(unittest.TestCase): return nativeFetch(url.pathname==='/api/reader-content'?location.origin+url.pathname+url.search:input,init); };''') page.goto(origin+'/static/manifest.json') - page.add_script_tag(url=origin+'/static/reader-navigation.js') + page.add_script_tag(url=origin+'/static/search/reader-navigation.js') page.evaluate('''url => { window.__navigation = VoiceOfMLReaderNavigation.createNavigation('/static/reader.html'); window.__reader = __navigation.mount(new URL(url)); diff --git a/tests/test_reader_ebook_flow.py b/tests/test_reader_ebook_flow.py index 9880dd0907cd40399d23650500caaf57855e4df1..5c0711a1412a58fc30a012e8499a7b43af194365 100644 --- a/tests/test_reader_ebook_flow.py +++ b/tests/test_reader_ebook_flow.py @@ -13,10 +13,17 @@ class EbookFlowTests(unittest.TestCase): setUp = support.PdfFollowupTests.setUp def expose(self): + def instrument_foliate(route): + response = route.fetch() + needle = 'readerContext.foliateSectionLoader.refresh = refresh;' + script = response.text() + self.assertIn(needle, script) + route.fulfill(response=response, body=script.replace(needle, + needle + ' window.__nativeStats=()=>({active:scheduler.activeCount,pending:scheduler.pendingCount});')) + self.page.route('**/static/reader/formats/reader-foliate-format.mjs*', instrument_foliate) def instrument(route): response = route.fetch() - script=response.text().replace('foliateSectionLoader.refresh = refresh;', - 'foliateSectionLoader.refresh = refresh; window.__nativeStats=()=>({active:scheduler.activeCount,pending:scheduler.pendingCount});') + script=response.text() route.fulfill(response=response, body=script+''' window.__ebook={restore:restoreFoliateBookmarkPosition,seek:seekFoliateProgress, begin:beginReaderNavigation,anchor:foliateScrollAnchors, diff --git a/tests/test_reader_engine_preload.js b/tests/test_reader_engine_preload.js index 9048a25cef532588cbf9500bb8fd9f54d7384f6c..d94eae97c7a86e7cbbd3e1369f77f035e90633c3 100644 --- a/tests/test_reader_engine_preload.js +++ b/tests/test_reader_engine_preload.js @@ -3,14 +3,14 @@ const path = require('path'); const vm = require('vm'); const assert = require('assert'); const root = process.argv[2] ? path.resolve(process.argv[2]) : path.resolve(__dirname,'..'); -const script = fs.readFileSync(path.join(root,'static/reader-engine-preload.js'),'utf8'); +const script = fs.readFileSync(path.join(root,'static/reader/pdf/reader-engine-preload.js'),'utf8'); const resources = require(path.join(root,'static/reader-resources.js')); for(const prefix of ['/static/','/search/static/']){ for(const [query,pdf] of [['?ext=pdf',true],['?ext=PDF',true],['?ext=txt',false],['?id=abc',false], ['?url='+encodeURIComponent('https://example.test/Book.PDF'),true],['?url=invalid&ext=txt',false]]){ const links=[]; vm.runInNewContext(script,{URL,URLSearchParams,location:{search:query},VoiceOfMLReaderResources:resources, - document:{currentScript:{src:'https://site.test'+prefix+'reader-engine-preload.abcdef123456.js'}, + document:{currentScript:{src:'https://site.test'+prefix+'reader/pdf/reader-engine-preload.abcdef123456.js'}, createElement:()=>({}),head:{appendChild:node=>links.push(node)}}}); assert.deepStrictEqual(links.map(link=>link.href),pdf?['pdf','pdfWorker'].map(name=>resources.vendorUrl(name,'https://site.test'+prefix)):[]); assert.ok(links.every(link=>link.rel==='modulepreload')); diff --git a/tests/test_reader_heading_cache.js b/tests/test_reader_heading_cache.js index 256149715ffe104ede073e3b8df24b5342298092..58a70cb2c5c6f4cd3225551bea99c86b736470a9 100644 --- a/tests/test_reader_heading_cache.js +++ b/tests/test_reader_heading_cache.js @@ -3,7 +3,7 @@ const vm = require('vm'); const assert = require('assert'); const path = require('path'); const root = process.argv[2] ? path.resolve(process.argv[2]) : path.resolve(__dirname, '..'); -const source = fs.readFileSync(path.join(root, 'static/reader.js'), 'utf8'); +const source = require('./reader_source_contract').readReaderSource(root); const helper = source.match(/function createHeadingLocationCache\(root, entries\) \{[\s\S]*?\n\}/)[0]; const observers = [], listeners = new Map(); class Observer { diff --git a/tests/test_reader_index_warming.py b/tests/test_reader_index_warming.py index 96ae3ba2e9c419418a069afa0ac4ed8498fc6338..d8c8352ffe2e9d4c4ed1d59a41f0c908ea5fb4fc 100644 --- a/tests/test_reader_index_warming.py +++ b/tests/test_reader_index_warming.py @@ -4,7 +4,7 @@ import unittest from unittest import mock try: - import app + from backend import app except ModuleNotFoundError: app = None @@ -54,7 +54,7 @@ class ReaderIndexWarmingTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(list(app.reader_record_indices), [0]) async def test_partial_reader_build_yields_to_search_and_publishes_only_complete_indexes(self): - from search_execution import SearchExecutor + from backend.search_execution import SearchExecutor app.records *= 700 app.build_indexes(build_words=False, build_reader=False) seen = [] diff --git a/tests/test_reader_initial_recovery.js b/tests/test_reader_initial_recovery.js index e24ff551878d0f87a4802c96daa1a52dd1b87c4c..e95c8061ca478102bcff24f2fd402356c93158e5 100644 --- a/tests/test_reader_initial_recovery.js +++ b/tests/test_reader_initial_recovery.js @@ -4,7 +4,7 @@ const path = require('node:path'); const vm = require('node:vm'); const source = fs.readFileSync(path.join(process.env.READER_ROOT || path.join(__dirname, '..'), - 'static/reader-request-manager.js'), 'utf8'); + 'static/reader/core/reader-request-manager.js'), 'utf8'); const tick = () => new Promise(resolve => setImmediate(resolve)); function harness(fetchImpl) { const timers = new Map(); diff --git a/tests/test_reader_models.js b/tests/test_reader_models.js index c8a08064b2535009d8bdf5a296d4173b3074ef43..cc54f3cd833ac139f5ef0cc6eccd1dc868a5a9d5 100644 --- a/tests/test_reader_models.js +++ b/tests/test_reader_models.js @@ -3,7 +3,7 @@ const fs = require("fs"); const path = require("path"); const vm = require("vm"); function random(seed) { let value = seed >>> 0; return () => ((value = Math.imul(value, 1664525) + 1013904223 >>> 0) / 0x100000000); } -function createRepository(options) { const sandbox = { self: {}, DOMException }; sandbox.self = sandbox; vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader-chapter-repository.js"), "utf8"), sandbox); return sandbox.VoiceOfMLReaderChapters.createChapterRepository(options); } +function createRepository(options) { const sandbox = { self: {}, DOMException }; sandbox.self = sandbox; vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader/formats/reader-chapter-repository.js"), "utf8"), sandbox); return sandbox.VoiceOfMLReaderChapters.createChapterRepository(options); } async function runRepositoryModel(seed) { const rand = random(seed), count = 17, present = new Map(), attempts = Array(count).fill(0), repository = createRepository({ count, find: (index) => present.get(index), create: async (index) => { attempts[index]++; if ((index + attempts[index] + seed) % 7 === 0) throw new Error("modeled failure"); return { index, attempt: attempts[index] }; }, commit: (index, value) => (present.set(index, value), value) }); for (let step = 0; step < 300; step++) { const index = Math.floor(rand() * count), operation = Math.floor(rand() * 3); if (operation === 0) { try { await repository.load(index); } catch (_) {} } else if (operation === 1) { present.delete(index); repository.release(index); } else { const first = repository.load(index), second = repository.load(index); if (repository.state(index).status === "loading") assert.strictEqual(first, second); try { await first; } catch (_) {} try { await second; } catch (_) {} } const state = repository.state(index); assert.ok(["idle", "loading", "ready", "error"].includes(state.status)); assert.strictEqual(state.attempts, attempts[index]); if (state.status === "ready") assert.strictEqual(state.value.index, index); } repository.dispose(); await assert.rejects(repository.load(0), (error) => error.name === "AbortError"); } -function runVirtualizerModel(seed) { const rand = random(seed); for (let round = 0; round < 120; round++) { const count = 10 + Math.floor(rand() * 40), limit = 3 + Math.floor(rand() * 10), center = Math.floor(rand() * count), removed = new Set(), nodes = Array.from({ length: count }, (_, index) => ({ index, height: 100 + index })), sandbox = { self: {} }; sandbox.self = sandbox; vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader-section-virtualizer.js"), "utf8"), sandbox); const virtualizer = sandbox.VoiceOfMLReaderVirtual.createSectionVirtualizer({ limit, getLoaded: () => nodes.filter((node) => !removed.has(node.index)), getIndex: (node) => node.index, getHeight: (node) => node.height, virtualize: (_node, index) => removed.add(index), release: () => {}, preserve: (change) => change() }), result = virtualizer.trim(center), retained = nodes.filter((node) => !removed.has(node.index)); assert.ok(retained.length <= limit); assert.ok(retained.some((node) => node.index === center)); assert.strictEqual(result.retained, retained.length); } } +function runVirtualizerModel(seed) { const rand = random(seed); for (let round = 0; round < 120; round++) { const count = 10 + Math.floor(rand() * 40), limit = 3 + Math.floor(rand() * 10), center = Math.floor(rand() * count), removed = new Set(), nodes = Array.from({ length: count }, (_, index) => ({ index, height: 100 + index })), sandbox = { self: {} }; sandbox.self = sandbox; vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader/formats/reader-section-virtualizer.js"), "utf8"), sandbox); const virtualizer = sandbox.VoiceOfMLReaderVirtual.createSectionVirtualizer({ limit, getLoaded: () => nodes.filter((node) => !removed.has(node.index)), getIndex: (node) => node.index, getHeight: (node) => node.height, virtualize: (_node, index) => removed.add(index), release: () => {}, preserve: (change) => change() }), result = virtualizer.trim(center), retained = nodes.filter((node) => !removed.has(node.index)); assert.ok(retained.length <= limit); assert.ok(retained.some((node) => node.index === center)); assert.strictEqual(result.retained, retained.length); } } (async () => { for (const seed of [1, 7, 42, 20260903]) { await runRepositoryModel(seed); runVirtualizerModel(seed); } console.log("reader model contracts passed"); })().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/tests/test_reader_navigation_host.py b/tests/test_reader_navigation_host.py index 18d7f5dbebb0dcf9f1bab8946d4c90b98051aa75..f7baf9089c6082f0ba40901f70f3bc213c5d1893 100644 --- a/tests/test_reader_navigation_host.py +++ b/tests/test_reader_navigation_host.py @@ -29,10 +29,10 @@ class ReaderNavigationHostTests(unittest.TestCase): root = Path(__file__).resolve().parents[1] shell = subprocess.check_output(['node', '--input-type=module', '-e', ''' import {readFileSync} from 'node:fs'; -import {stripBundledScripts} from './scripts/compose_app.mjs'; +import {stripBundledScripts} from './scripts/build/compose_app.mjs'; process.stdout.write(stripBundledScripts(readFileSync('static/index.html','utf8'))); '''], cwd=root) - bundle = subprocess.check_output(['node', 'scripts/compose_app.mjs'], cwd=root) + bundle = subprocess.check_output(['node', 'scripts/build/compose_app.mjs'], cwd=root) with self.fixture.browser.new_context(service_workers='block') as context: page = context.new_page() page.on('pageerror', lambda error: self.fixture.errors.append(str(error))) @@ -54,7 +54,7 @@ process.stdout.write(stripBundledScripts(readFileSync('static/index.html','utf8' const trigger=document.createElement('button');document.body.append(trigger);trigger.focus(); const existing=document.createElement('div');existing.setAttribute('inert',''); existing.setAttribute('aria-hidden','false');document.body.append(existing); - const readerPath=new URL(document.querySelector('script[src*="reader-navigation.js"]').src).pathname.replace('reader-navigation.js','reader.html'); + const readerPath=new URL(document.querySelector('script[src*="search/reader-navigation.js"]').src).pathname.replace('search/reader-navigation.js','reader.html'); const target=readerPath+'?ext=txt&url=https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/a.txt'; const returnUrl=location.href; navigateToReader(target);const first=readerOverlay; @@ -149,7 +149,7 @@ process.stdout.write(stripBundledScripts(readFileSync('static/index.html','utf8' Link:'https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/next.txt',Folder:[]}))); }; try { - const path=new URL(document.querySelector('script[src*="reader-navigation.js"]').src).pathname.replace('reader-navigation.js','reader.html'); + const path=new URL(document.querySelector('script[src*="search/reader-navigation.js"]').src).pathname.replace('search/reader-navigation.js','reader.html'); navigateToReader(path+'?ext=txt');closeReaderOverlay();history.replaceState(null,'',previousUrl); await randomTxt();await new Promise(resolve=>originalTimer(resolve,100)); const recovered=aborted&&!!readerOverlay; @@ -256,7 +256,7 @@ process.stdout.write(stripBundledScripts(readFileSync('static/index.html','utf8' def test_untrusted_and_stale_messages_cannot_change_active_reader(self): result = self.page.evaluate('''() => { - const readerPath=new URL(document.querySelector('script[src*="reader-navigation.js"]').src).pathname.replace('reader-navigation.js','reader.html'); + const readerPath=new URL(document.querySelector('script[src*="search/reader-navigation.js"]').src).pathname.replace('search/reader-navigation.js','reader.html'); const target=readerPath+'?ext=txt';navigateToReader(target); const first=readerOverlay, oldSource=first.contentWindow, theme=STATE.isDark; const message={type:'voice-reader-open',url:target+'&title=bad'}; @@ -273,7 +273,7 @@ process.stdout.write(stripBundledScripts(readFileSync('static/index.html','utf8' def test_unavailable_session_storage_does_not_block_replacement(self): result = self.page.evaluate('''() => { - const readerPath=new URL(document.querySelector('script[src*="reader-navigation.js"]').src).pathname.replace('reader-navigation.js','reader.html'); + const readerPath=new URL(document.querySelector('script[src*="search/reader-navigation.js"]').src).pathname.replace('search/reader-navigation.js','reader.html'); const target=readerPath+'?ext=txt';navigateToReader(target);const first=readerOverlay; const original=Storage.prototype.setItem; try { diff --git a/tests/test_reader_ocr_transport.mjs b/tests/test_reader_ocr_transport.mjs index 7a8594c8180c3a9b7e9a8bc17e4c9ef6dd4e149d..53595df4848bfd920ff075e09a5e6e3eeb29e98d 100644 --- a/tests/test_reader_ocr_transport.mjs +++ b/tests/test_reader_ocr_transport.mjs @@ -3,10 +3,10 @@ import fs from 'node:fs'; import vm from 'node:vm'; import { webcrypto } from 'node:crypto'; import { gzipSync } from 'node:zlib'; -import { createPdfBookSearch } from '../static/reader-pdf-book-search.mjs'; -import { validateBookText, searchBookText } from '../static/reader-book-text.js'; +import { createPdfBookSearch } from '../static/reader/search/reader-pdf-book-search.mjs'; +import { validateBookText, searchBookText } from '../static/reader/search/reader-book-text.js'; -const source=fs.readFileSync(new URL('../static/reader-pdf-book-search-worker.mjs',import.meta.url),'utf8'); +const source=fs.readFileSync(new URL('../static/reader/search/workers/reader-pdf-book-search-worker.mjs',import.meta.url),'utf8'); const start=source.indexOf('async function readBounded('), end=source.indexOf('\nasync function loadBook()',start); const read=vm.runInNewContext('const limit=1024; const aborted=()=>new DOMException("cancelled","AbortError");'+ source.slice(start,end)+';readBounded',{TextDecoder,Uint8Array,DOMException}); diff --git a/tests/test_reader_optimizations.py b/tests/test_reader_optimizations.py index 6013d329f2fda5e9aa37836f297c262712cc0490..fb4997f2a48c668ffa582e94d7e78541ae0d8f8c 100644 --- a/tests/test_reader_optimizations.py +++ b/tests/test_reader_optimizations.py @@ -24,12 +24,19 @@ class ReaderOptimizationTests(unittest.TestCase): def expose(self): fixtures.expose_dom_search_tools(self.page) - def instrument(route): + def instrument_chapters(route): response = route.fetch() - script = response.text().replace('chapterManifestCleanup = () => {', ''' + needle = 'readerContext.chapterManifestCleanup = () => {' + script = response.text() + self.assertIn(needle, script) + route.fulfill(response=response, body=script.replace(needle, ''' window.__chapterStats = () => ({active: chapterScheduler.activeCount, pending: chapterScheduler.pendingCount, bytes: chapterBudget.used}); - chapterManifestCleanup = () => {''') + ''' + needle)) + self.page.route('**/static/reader/formats/reader-chapter-format.mjs*', instrument_chapters) + def instrument(route): + response = route.fetch() + script = response.text() route.fulfill(response=response, body=script + ''' window.__chunkWindow = createPdfShellWindow; window.__snapshot = () => window.__domSearchTools.snapshot(); @@ -578,13 +585,13 @@ class ReaderOptimizationTests(unittest.TestCase): prefix='''const parse=JSON.parse;JSON.parse=(...args)=>{self.postMessage({parsing:true}); const until=performance.now()+10000;while(performance.now(){ const Native=Worker;window.__workers=[]; window.Worker=class extends Native{constructor(...args){super(...args);__workers.push(this); this.addEventListener('message',event=>{if(event.data.parsing)window.__parsing=true;});} terminate(){this.wasTerminated=true;super.terminate();}}; - const {createPdfBookSearch}=await import('/static/reader-pdf-book-search.mjs'); + const {createPdfBookSearch}=await import('/static/reader/search/reader-pdf-book-search.mjs'); window.__ocr=createPdfBookSearch(configuration); window.__firstSearch=__ocr.search('needle').catch(error=>error.name); }''',configuration) diff --git a/tests/test_reader_pdf_followup.py b/tests/test_reader_pdf_followup.py index f9dfdcf9045d042dd737129ac56c58b786a64e1c..2189d5e54a64e7c4f9c3f9941cca18b49595b488 100644 --- a/tests/test_reader_pdf_followup.py +++ b/tests/test_reader_pdf_followup.py @@ -111,7 +111,7 @@ class PdfFollowupTests(unittest.TestCase): def test_range_headers_and_first_chunk_are_available_before_end_of_body(self): self.page.goto(self.origin+PREFIX+'/static/reader.html?ext=txt') result=self.page.evaluate('''async()=>{ - const {createPdfFetchPolicy}=await import('./reader-pdf-network.mjs'),native=fetch; + const {createPdfFetchPolicy}=await import('./reader/pdf/reader-pdf-network.mjs'),native=fetch; let source;globalThis.fetch=async()=>new Response(new ReadableStream({start(c){source=c;c.enqueue(new Uint8Array([1,2]));}}), {status:206,headers:{'Content-Range':'bytes 0-3/4',ETag:'"A"'}}); const policy=createPdfFetchPolicy({idleMs:1000});policy.add(['/stream.pdf']); @@ -128,7 +128,7 @@ class PdfFollowupTests(unittest.TestCase): def test_initial_and_conditional_fetches_keep_their_existing_header_deadline(self): self.page.goto(self.origin+PREFIX+'/static/reader.html?ext=txt') result=self.page.evaluate('''async()=>{ - const {createPdfFetchPolicy}=await import('./reader-pdf-network.mjs'),native=fetch; + const {createPdfFetchPolicy}=await import('./reader/pdf/reader-pdf-network.mjs'),native=fetch; let calls=0;globalThis.fetch=async(_url,init)=>{ calls++;await new Promise(resolve=>setTimeout(resolve,70));init.signal.throwIfAborted();return new Response('complete');}; const policy=createPdfFetchPolicy({idleMs:15,attemptMs:30});policy.add(['/slow.pdf']); @@ -146,8 +146,8 @@ class PdfFollowupTests(unittest.TestCase): suffix=re.sub(rb'startxref\n\d+',b'startxref\n'+str(xref+len(extra)).encode(),suffix) packed=small[:xref]+extra+suffix self.page.unroute('**/static/vendor/pdf.min.*.mjs') - self.page.route('**/reader-pdf-network.mjs',lambda route:route.fulfill(content_type='text/javascript',body= - (ROOT/'static/reader-pdf-network.mjs').read_text().replace('idleMs = 15000','idleMs = 80'))) + self.page.route('**/reader/pdf/reader-pdf-network.mjs',lambda route:route.fulfill(content_type='text/javascript',body= + (ROOT/'static/reader/pdf/reader-pdf-network.mjs').read_text().replace('idleMs = 15000','idleMs = 80'))) prefix=small[:xref]+extra[:extra.index(b'stream\n')+7] tail=b'\nendstream\nendobj\n'+suffix setup=f'const source=new Uint8Array({len(packed)});source.set({json.dumps(list(prefix))});source.set({json.dumps(list(tail))},{len(packed)-len(tail)});' @@ -192,7 +192,7 @@ class PdfFollowupTests(unittest.TestCase): def test_optional_cache_module_failure_does_not_block_opening_or_search(self): requested=[] - self.page.route('**/reader-pdf-text-store.mjs',lambda route:(requested.append(route.request.url),route.abort())) + self.page.route('**/reader/pdf/reader-pdf-text-store.mjs',lambda route:(requested.append(route.request.url),route.abort())) self.open() self.assertEqual(requested,[]) self.search('needleA',12) @@ -212,7 +212,7 @@ class PdfFollowupTests(unittest.TestCase): def test_range_recovery_is_bounded_and_never_mixes_versions(self): self.page.goto(self.origin+PREFIX+'/static/reader.html?ext=txt') result=self.page.evaluate('''async()=>{ - const {createPdfFetchPolicy}=await import('./reader-pdf-network.mjs'); + const {createPdfFetchPolicy}=await import('./reader/pdf/reader-pdf-network.mjs'); const native=fetch,rows=[]; const headers={'Content-Range':'bytes 0-3/4','ETag':'"A"'}; async function run(mode){let calls=0,cancels=0; @@ -250,7 +250,7 @@ class PdfFollowupTests(unittest.TestCase): def test_disk_store_preserves_unicode_bounds_expiry_and_failure_fallback(self): self.page.goto(self.origin+PREFIX+'/static/reader.html?ext=txt') result=self.page.evaluate('''async()=>{ - const {createPdfTextStore,pdfTextStoreKey}=await import('./reader-pdf-text-store.mjs'); + const {createPdfTextStore,pdfTextStoreKey}=await import('./reader/pdf/reader-pdf-text-store.mjs'); const input={source:'https://example.test/book.pdf',version:'"A"',pageCount:12,engine:'engine1'}; const key=await pdfTextStoreKey(input),other=await pdfTextStoreKey({...input,version:'"B"'}); let clock=1000000;const options={now:()=>clock,limits:{bytes:600,entries:2,entryBytes:500,ttl:1000}}; diff --git a/tests/test_reader_pdf_hybrid.py b/tests/test_reader_pdf_hybrid.py index 95614b0c8a48edd4f1dccefb902d99c7de8c3770..e351c9707ed4e5bd5d4ebe05595cd1cf189f5af8 100644 --- a/tests/test_reader_pdf_hybrid.py +++ b/tests/test_reader_pdf_hybrid.py @@ -98,10 +98,10 @@ class HybridPdfTests(unittest.TestCase): def open(self, page=1): source = ("https://voiceofml-search.hf.space" if PAGES else self.origin) + "/api/reader-bucket-resource?" + urlencode({"path": PATH}) if page != 1: - script = (ROOT / "static/reader-store.js").read_text() + "\n" + ( + script = (ROOT / "static/reader/core/reader-store.js").read_text() + "\n" + ( "window.VoiceOfMLReaderStore=Object.freeze({...window.VoiceOfMLReaderStore," "get:async url=>({schema:3,url,page:" + str(page) + ",pageCount:80,pageOffset:0})});") - self.page.route("**/static/reader-store.js*", lambda route: route.fulfill( + self.page.route("**/static/reader/core/reader-store.js*", lambda route: route.fulfill( content_type="text/javascript", body=script)) self.page.goto(self.origin + BASE_PATH + "/static/reader.html?" + urlencode( {"url": source, "ext": "pdf-pages", "pdf_document": PDF}), wait_until="domcontentloaded") diff --git a/tests/test_reader_pdf_loading.py b/tests/test_reader_pdf_loading.py index d8277333262c814d0b82f19135dba25721820e28..597000cd3103fa677c5c7d762b93e59cfc84c51a 100644 --- a/tests/test_reader_pdf_loading.py +++ b/tests/test_reader_pdf_loading.py @@ -121,7 +121,7 @@ class PdfLoadingTests(unittest.TestCase): self.browser.new_context(service_workers='block') as context: page = context.new_page() page.goto(self.origin + '/static/manifest.json') - page.evaluate("async () => { window.__pdfNetwork = await import('/static/reader-pdf-network.mjs'); }") + page.evaluate("async () => { window.__pdfNetwork = await import('/static/reader/pdf/reader-pdf-network.mjs'); }") for mode in ('upstream', 'reader'): results = page.evaluate('''async mode => { const url = '/cache-lock-probe.pdf?mode=' + mode; @@ -145,7 +145,7 @@ class PdfLoadingTests(unittest.TestCase): page = context.new_page() page.goto(self.origin + '/static/manifest.json') result = page.evaluate('''async () => { - const {createPdfFetchPolicy} = await import('/static/reader-pdf-network.mjs'); + const {createPdfFetchPolicy} = await import('/static/reader/pdf/reader-pdf-network.mjs'); const native = fetch, calls = [], signal = new AbortController().signal; const init = {cache:'force-cache', signal, headers:{Range:'bytes=0-1'}}; const spy = (input, options) => {calls.push({input, options}); return Promise.resolve(new Response('ok'));}; @@ -494,7 +494,7 @@ class PdfLoadingTests(unittest.TestCase): def test_engine_preload_starts_while_reader_dependency_is_still_pending(self): held=[] - self.page.route('**/static/reader-runtime.js',lambda route:held.append(route)) + self.page.route('**/static/reader/core/reader-runtime.js',lambda route:held.append(route)) self.page.goto(self.origin+'/static/reader.html?'+urllib.parse.urlencode(dict(url=SOURCE,ext='pdf')),wait_until='commit') self.page.locator('link[rel="modulepreload"][href*="pdf.min"]').wait_for(state='attached') for _ in range(100): @@ -503,7 +503,7 @@ class PdfLoadingTests(unittest.TestCase): self.assertGreaterEqual(len(self.modules),1) self.assertEqual(len(self.documents),0) self.assertEqual(len(held),1) - self.page.unroute('**/static/reader-runtime.js') + self.page.unroute('**/static/reader/core/reader-runtime.js') held[0].continue_() self.ready() self.assertEqual(len(self.modules),1) diff --git a/tests/test_reader_performance.py b/tests/test_reader_performance.py index bf9d67aa1ccdb2e6a4024172f4d873122f79ee39..e3d70b009d7698d84f058b9bf9849f51aea25461 100644 --- a/tests/test_reader_performance.py +++ b/tests/test_reader_performance.py @@ -298,7 +298,7 @@ class ReaderPerformanceTest(unittest.TestCase): self.context = self.browser.new_context(viewport={"width": 1440, "height": 900}) self.page = self.context.new_page() self.pdf_requested_at = None - self.page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + self.page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) self.page.route("**/static/vendor/pdf.min.*.mjs", self.route_pdf) self.page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/pdf", body=b"pdf")) @@ -638,7 +638,7 @@ class ReaderPerformanceTest(unittest.TestCase): mobile_context = self.browser.new_context(viewport={"width": 390, "height": 844}) mobile_page = mobile_context.new_page() - mobile_page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + mobile_page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) mobile_page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill(status=200, content_type="text/javascript", body=PDF_MODULE)) mobile_page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/pdf", body=b"pdf")) query = urllib.parse.quote(SOURCE_URL, safe="") @@ -718,7 +718,7 @@ class ReaderPerformanceTest(unittest.TestCase): with self.subTest(extension=extension): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill(status=200, content_type="text/javascript", body=PDF_MODULE)) if extension == "md": page.route("**/static/vendor/marked.min.*.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=MARKED_SCRIPT)) @@ -815,7 +815,7 @@ class ReaderPerformanceTest(unittest.TestCase): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() source = "https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/objects/aa/" + "a" * 64 + "/page-manifest.json" - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route, _request, manifest=manifest: route.fulfill(status=200, content_type="application/json", body=json.dumps(manifest))) page.route("https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/objects/aa/**", lambda route: route.fulfill(status=404, body=b"")) page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=pdf-pages&title=Converted", wait_until="domcontentloaded") @@ -834,7 +834,7 @@ class ReaderPerformanceTest(unittest.TestCase): root = "https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/objects/aa/" + "a" * 64 + "/1234567890abcdef" manifest = {"version": 2, "kind": "pdf-pages", "page_count": 4} missing_requests = [] - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/json", body=json.dumps(manifest))) def serve_page(route, _request): if not route.request.url.endswith("page-000004.webp"): @@ -955,7 +955,7 @@ class ReaderPerformanceTest(unittest.TestCase): manifest = {"version": 2, "kind": "pdf-pages", "page_count": 3, "toc": [{"title": "第一章", "page": 1, "depth": 0}, {"title": "第二章", "page": 2, "depth": 1}]} # Three wide pages must fit even the minimum retained-pixel budget. wide_page = b'' - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/json", body=json.dumps(manifest))) page.route(f"{root}/pages/**", lambda route: route.fulfill(status=200, content_type="image/svg+xml", body=wide_page)) page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=pdf-pages&title=Converted", wait_until="domcontentloaded") @@ -989,7 +989,7 @@ class ReaderPerformanceTest(unittest.TestCase): source = root + "/page-manifest.json" manifest = {"version": 2, "kind": "pdf-pages", "source_sha256": "a" * 64, "profile": "test", "page_count": 5000} - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route(source, lambda route: route.fulfill(status=200, content_type="application/json", body=json.dumps(manifest))) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/json", body=json.dumps(manifest))) page.route(f"{root}/pages/**", lambda route: route.fulfill(status=200, content_type="image/webp", body=IMAGE_FIXTURES["webp"][1])) @@ -1179,8 +1179,8 @@ class ReaderPerformanceTest(unittest.TestCase): "get: () => new Promise((resolve) => setTimeout(() => resolve(null), 300))", "get: () => new Promise((resolve) => setTimeout(() => resolve({url: location.href, scrollTop: 640, zoom: 100}), 1200))", ) - self.page.unroute("**/static/reader-store.js") - self.page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=delayed_store)) + self.page.unroute("**/static/reader/core/reader-store.js") + self.page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=delayed_store)) query = urllib.parse.quote(SOURCE_URL, safe="") self.page.goto(f"{self.origin}/static/reader.html?url={query}&ext=pdf&title=Performance", wait_until="domcontentloaded") self.page.evaluate("""() => { @@ -1245,7 +1245,7 @@ class ReaderPerformanceTest(unittest.TestCase): listBookmarks: (url) => new Promise((resolve) => setTimeout(() => resolve([{id:'local', url, title:'Current book', label:'Current mark', createdAt:1}]), 180)), listAllBookmarks: () => new Promise((resolve) => setTimeout(() => resolve([{id:'all', url:'other', readerUrl:location.href, title:'All book', label:'All mark', createdAt:2}]), 10)) });""" - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=store)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=store)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="text/plain", body=b"Reader")) source = urllib.parse.quote("https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/race.txt", safe="") page.goto(f"{self.origin}/static/reader.html?url={source}&ext=txt&title=Race", wait_until="domcontentloaded") @@ -1262,7 +1262,7 @@ class ReaderPerformanceTest(unittest.TestCase): def test_truncated_epub_reports_source_damage(self): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=b"PK\x03\x04truncated")) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/damaged.epub" page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=Damaged", wait_until="domcontentloaded") @@ -1293,7 +1293,7 @@ class ReaderPerformanceTest(unittest.TestCase): for name, headers, chapter_headers in (("manifest", {"content-length": str(limit + 1)}, None), ("chapter", {}, {"content-length": str(8 * 1024 * 1024 + 1)})): with self.subTest(case=name): context = self.browser.new_context(viewport={"width": 390, "height": 844}); page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) def serve_limited(route, _request, headers=headers, chapter_headers=chapter_headers): if chapter_headers and "chapter.xhtml" in route.request.url: route.fulfill(status=200, content_type="text/html", headers=chapter_headers, body=b"

chapter

") @@ -1315,7 +1315,7 @@ class ReaderPerformanceTest(unittest.TestCase): context = self.browser.new_context(viewport={"width": 390, "height": 844}) self.addCleanup(context.close) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/static/vendor/jszip.min.*.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body="window.JSZip=function(){window.__archiveParserStarted=true};")) page.route("**/static/vendor/docx-preview.min.*.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body="window.docx={parseAsync(){window.__archiveParserStarted=true}};")) page.route("**/static/foliate-reader/view.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body="customElements.define('foliate-view', class extends HTMLElement { open() { window.__archiveParserStarted=true; throw new Error('parser must not start'); } });")) @@ -1329,7 +1329,7 @@ class ReaderPerformanceTest(unittest.TestCase): context.close() def test_actual_store_broadcasts_progress_and_bookmark_updates_between_readers(self): - self.page.unroute("**/static/reader-store.js"); self.page.unroute("**/api/reader-content**") + self.page.unroute("**/static/reader/core/reader-store.js"); self.page.unroute("**/api/reader-content**") self.page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="text/plain", body=b"Shared reader")) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/shared.txt"; query = urllib.parse.quote(source, safe="") self.page.goto(f"{self.origin}/static/reader.html?url={query}&ext=txt&title=Shared", wait_until="domcontentloaded"); self.page.locator(".reader-text").wait_for() @@ -1342,7 +1342,7 @@ class ReaderPerformanceTest(unittest.TestCase): for extension, body in (("html", b"

HTML one

space

HTML two

"), ("md", b"# Markdown one\n\n## Markdown two")): with self.subTest(extension=extension): context = self.browser.new_context(viewport={"width": 390, "height": 844}); page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) if extension == "md": page.route("**/static/vendor/marked.min.*.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body="window.marked={parse:()=>'

Markdown one

Markdown two

'};")); page.route("**/static/vendor/purify.min.*.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=PURIFY_SCRIPT)) page.route("**/api/reader-content**", lambda route, _request, body=body: route.fulfill(status=200, content_type="text/html" if extension == "html" else "text/markdown", body=body)) @@ -1476,7 +1476,7 @@ class ReaderPerformanceTest(unittest.TestCase): errors = [] page.on("pageerror", lambda error: errors.append(str(error))) page.add_init_script(f"window.__holdPdfLoad = {json.dumps(phase == 'loading')}") - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) requests = [] page.on("request", lambda request: requests.append(request.url) if "reader-content" in request.url or request.url == SOURCE_URL else None) page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill(status=200, content_type="text/javascript", body=PDF_TASK_MODULE)) @@ -1696,7 +1696,7 @@ class ReaderPerformanceTest(unittest.TestCase): self.addCleanup(context.close) page = context.new_page() page.add_init_script("localStorage.setItem('theme', 'dark')") - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=epub_with_navigation())) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/e2e.epub" url = f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=E2E&path=Test%2Fbooks" @@ -1738,7 +1738,7 @@ class ReaderPerformanceTest(unittest.TestCase): def test_foliate_rapid_toc_navigation_keeps_latest_destination(self): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=epub_with_many_chapters())) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/race.epub" page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=Race", wait_until="domcontentloaded") @@ -1764,7 +1764,7 @@ class ReaderPerformanceTest(unittest.TestCase): page = context.new_page() errors = [] page.on("pageerror", lambda error: errors.append(str(error))) - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=epub_with_many_chapters())) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/retry.epub" page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=Retry", wait_until="domcontentloaded") @@ -1792,7 +1792,7 @@ class ReaderPerformanceTest(unittest.TestCase): def test_foliate_duplicate_toc_navigation_shares_section_load(self): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=epub_with_many_chapters())) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/deduplicate.epub" page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=Deduplicate", wait_until="domcontentloaded") @@ -1814,7 +1814,7 @@ class ReaderPerformanceTest(unittest.TestCase): def test_foliate_chapter_buttons_use_continuous_reader_navigation(self): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=epub_with_many_chapters())) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/chapter-buttons.epub" page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=Buttons", wait_until="domcontentloaded") @@ -1866,7 +1866,7 @@ class ReaderPerformanceTest(unittest.TestCase): files['META-INF/reader-chm.json'] = json.dumps({'version': 1, 'previous_path': root + 'calibre-chm-epub-v2/document.epub', 'previous_sections': ['OEBPS/nav.xhtml', 'OEBPS/chapter-1.xhtml', 'OEBPS/chapter-2.xhtml']}) - self.page.route('**/static/reader-store.js', lambda route: route.fulfill( + self.page.route('**/static/reader/core/reader-store.js', lambda route: route.fulfill( status=200, content_type='text/javascript', body=STORE_SCRIPT.replace( 'get: () => new Promise((resolve) => setTimeout(() => resolve(null), 300))', "get: url => Promise.resolve(url.includes('calibre-chm-epub-v2') ? {foliateSection:2, foliateOffset:100, foliateTocIndex:1} : null)"))) @@ -1886,7 +1886,7 @@ class ReaderPerformanceTest(unittest.TestCase): def test_foliate_scroll_updates_toc_on_animation_frame(self): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=epub_with_many_chapters())) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/scroll-toc.epub" page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=ScrollToc", wait_until="domcontentloaded") @@ -1902,7 +1902,7 @@ class ReaderPerformanceTest(unittest.TestCase): def test_foliate_resize_keeps_current_text_anchor(self): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=epub_with_many_chapters())) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/scroll-anchor.epub" page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=Anchor", wait_until="domcontentloaded") @@ -1920,7 +1920,7 @@ class ReaderPerformanceTest(unittest.TestCase): def test_foliate_virtualizes_distant_sections_and_reloads_them(self): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=epub_with_many_chapters())) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/virtual.epub" page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=Virtual", wait_until="domcontentloaded") @@ -1942,7 +1942,7 @@ class ReaderPerformanceTest(unittest.TestCase): def test_large_foliate_toc_keeps_a_bounded_dom_window(self): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=epub_with_many_chapters(600))) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/large-toc.epub" page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=LargeToc", wait_until="domcontentloaded") @@ -1960,7 +1960,7 @@ class ReaderPerformanceTest(unittest.TestCase): def test_foliate_full_search_uses_continuous_reader_navigation(self): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=epub_with_many_chapters())) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/search.epub" page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=Search", wait_until="domcontentloaded") @@ -1979,7 +1979,7 @@ class ReaderPerformanceTest(unittest.TestCase): def test_foliate_bookmark_restores_continuous_section_position(self): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=epub_with_many_chapters())) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/bookmark.epub" page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=Bookmark", wait_until="domcontentloaded") @@ -2004,7 +2004,7 @@ class ReaderPerformanceTest(unittest.TestCase): def test_foliate_progress_slider_uses_continuous_viewport(self): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=epub_with_many_chapters())) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/progress.epub" page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=Progress", wait_until="domcontentloaded") @@ -2030,7 +2030,7 @@ class ReaderPerformanceTest(unittest.TestCase): def test_foliate_history_saves_structured_section_position(self): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=epub_with_many_chapters())) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/history.epub" page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=History", wait_until="domcontentloaded") @@ -2047,7 +2047,7 @@ class ReaderPerformanceTest(unittest.TestCase): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() restored_store = STORE_SCRIPT.replace("get: () => new Promise((resolve) => setTimeout(() => resolve(null), 300)),", "get: () => Promise.resolve({foliateSection: 10, foliateOffset: 0, foliateTocIndex: 9, zoom: 1}),") - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=restored_store)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=restored_store)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="application/epub+zip", body=epub_with_many_chapters())) source = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/restore.epub" page.goto(f"{self.origin}/static/reader.html?url={urllib.parse.quote(source, safe='')}&ext=epub&title=Restore", wait_until="domcontentloaded") @@ -2057,7 +2057,7 @@ class ReaderPerformanceTest(unittest.TestCase): "get: () => Promise.resolve({foliateSection: 10, foliateOffset: 0, foliateTocIndex: 9, zoom: 1}),", "get: () => new Promise(resolve => { window.__releaseHistory = () => resolve({foliateSection: 10, foliateOffset: 0, foliateTocIndex: 9, zoom: 1}); }),", ) - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=delayed_store)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=delayed_store)) page.reload(wait_until="domcontentloaded") page.wait_for_function("() => document.querySelectorAll('#toc-list .toc-item').length === 14") page.locator("#history").click() @@ -2113,7 +2113,7 @@ class ReaderPerformanceTest(unittest.TestCase): viewport={"width": 390, "height": 844}, device_scale_factor=3 ) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill( + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill( status=200, content_type="text/javascript", body=STORE_SCRIPT )) page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill( @@ -2263,7 +2263,7 @@ class ReaderPerformanceTest(unittest.TestCase): with self.subTest(title=title, size=len(encoded)): context = self.browser.new_context(viewport={"width": 1440, "height": 900}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.add_init_script(""" window.__txtBytes = new Uint8Array(%s); const nativeFetch = window.fetch.bind(window); @@ -2284,7 +2284,7 @@ class ReaderPerformanceTest(unittest.TestCase): with self.subTest(extension=extension): context = self.browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/static/vendor/marked.min.*.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=MARKED_SCRIPT)) page.route("**/static/vendor/purify.min.*.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=PURIFY_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="text/markdown", body=b"# Markdown readable")) @@ -2406,7 +2406,7 @@ class ReaderPerformanceTest(unittest.TestCase): "get: () => new Promise((resolve) => setTimeout(() => resolve(null), 300))", "get: () => new Promise((resolve) => { window.__releaseHistory = () => { window.__historyResolved = true; resolve(null); }; })", ) - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=pending_store)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=pending_store)) page.route("**/static/vendor/marked.min.*.js", timed("marked", "text/javascript", MARKED_SCRIPT)) page.route("**/static/vendor/purify.min.*.js", timed("purify", "text/javascript", PURIFY_SCRIPT)) page.route("**/static/vendor/jszip.min.*.js", timed("jszip", "text/javascript", JSZIP_SCRIPT)) diff --git a/tests/test_reader_preload.js b/tests/test_reader_preload.js index 6f13d39db1ad4e4d05b43ad429faadc3dfe91f57..7bb23a0adaf02bf3802d4f41d049b5d84f3fa549 100644 --- a/tests/test_reader_preload.js +++ b/tests/test_reader_preload.js @@ -42,13 +42,13 @@ function loadPreload({ chunks = [], header, security = true, blockRead = false, }; sandbox.self = sandbox; if (security) { - vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader-security.js"), "utf8"), sandbox); + vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader/core/reader-security.js"), "utf8"), sandbox); sandbox.VoiceOfMLReaderSecurity = { ...sandbox.VoiceOfMLReaderSecurity, LIMITS: { ...sandbox.VoiceOfMLReaderSecurity.LIMITS, manifestBytes: 8 }, }; } - vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader-contract.js"), "utf8"), sandbox); + vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader/core/reader-contract.js"), "utf8"), sandbox); return { sandbox, preload: sandbox.__VOICE_PDF_PRELOAD__, contract: sandbox.VoiceOfMLReader, timers, listeners, state, reading }; } @@ -72,7 +72,7 @@ async function main() { const image = loadPreload({ chunks: [Buffer.from("ok")], saveData: false }); image.sandbox.VoiceOfMLReader = { pdfPageSource() {}, txtRelativePath() {} }; - vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader-contract.js"), "utf8"), image.sandbox); + vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader/core/reader-contract.js"), "utf8"), image.sandbox); assert.strictEqual(typeof image.sandbox.VoiceOfMLReader.assetFields, "function"); assert.strictEqual(image.sandbox.__VOICE_PDF_PRELOAD__, image.preload); assert.strictEqual(image.state.fetches, 1); diff --git a/tests/test_reader_range_flights.py b/tests/test_reader_range_flights.py index 7e1c1bf38ec89a81be7d9676ea6e25d4f058feac..ab4ba55359a151e37476a0d3602bfbb236938d9f 100644 --- a/tests/test_reader_range_flights.py +++ b/tests/test_reader_range_flights.py @@ -3,7 +3,7 @@ import asyncio import unittest from types import SimpleNamespace -import app +from backend import app from tests.test_async_proxy import FakeResponse, FakeSession URL = 'https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/shared.pdf' diff --git a/tests/test_reader_refactor.py b/tests/test_reader_refactor.py index fa6ba6920abf8f215d3dba231d933aecd4ba94a9..95edac01ade97cbe7cf4c62bf33ad7a7f6b7738e 100644 --- a/tests/test_reader_refactor.py +++ b/tests/test_reader_refactor.py @@ -29,7 +29,7 @@ def expose_dom_search_tools(page): location: fullSearchLocation }; ''' + marker)) - page.route("**/static/reader-dom-search.mjs*", instrument) + page.route("**/static/reader/search/reader-dom-search.mjs*", instrument) @unittest.skipIf(support.sync_playwright is None, "Playwright is unavailable") @@ -84,7 +84,7 @@ class ReaderRefactorTest(unittest.TestCase): "get: () => new Promise((resolve) => setTimeout(() => resolve(null), 300)),", "get: () => new Promise(resolve => { window.__releaseRestore = () => resolve({url: location.href, page: 1, zoom: 220}); }),", ) - self.page.route("**/static/reader-store.js", lambda route: route.fulfill( + self.page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill( content_type="text/javascript", body=store)) self.serve(support.minimal_pdf(), "application/pdf") self.page.goto(self.reader_url("pdf"), wait_until="domcontentloaded") @@ -104,7 +104,7 @@ class ReaderRefactorTest(unittest.TestCase): "get: () => new Promise((resolve) => setTimeout(() => resolve(null), 300)),", "get: (url) => Promise.resolve(url.includes('refactor.pdf') ? {url, page: 1, zoom: 175} : null),", ) - self.page.route("**/static/reader-store.js", lambda route: route.fulfill( + self.page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill( content_type="text/javascript", body=store)) self.serve(support.minimal_pdf(), "application/pdf") self.page.goto(self.reader_url("pdf")) @@ -297,7 +297,7 @@ class ReaderRefactorTest(unittest.TestCase): "getPage: () => Promise.resolve(page)", "getPage: (number) => { (window.__pdfPageRequests ||= []).push(number); return Promise.resolve(page); }", ) - self.page.route("**/static/reader-store.js", lambda route: route.fulfill(content_type="text/javascript", body=store)) + self.page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(content_type="text/javascript", body=store)) self.page.route("**/static/vendor/pdf.min.*.mjs", lambda route: route.fulfill(content_type="text/javascript", body=module)) self.serve(b"pdf", "application/pdf") self.open(self.reader_url("pdf")) @@ -313,7 +313,7 @@ class ReaderRefactorTest(unittest.TestCase): manifest = {"version": 2, "kind": "pdf-pages", "page_count": 24} root = "https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/objects/aa/" + "a" * 64 requests = [] - self.page.route("**/static/reader-store.js", lambda route: route.fulfill(content_type="text/javascript", body=store)) + self.page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(content_type="text/javascript", body=store)) self.serve(json.dumps(manifest), "application/json") self.page.route("**/page-manifest.json", lambda route: route.fulfill(json=manifest)) def image(route): @@ -353,7 +353,7 @@ class ReaderRefactorTest(unittest.TestCase): {"index": i, "path": f"chapter-{i}.xhtml", "bytes": 100} for i in range(1, 13) ]} requested = [] - self.page.route("**/static/reader-store.js", lambda route: route.fulfill(content_type="text/javascript", body=store)) + self.page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(content_type="text/javascript", body=store)) def resource(route): url = urllib.parse.parse_qs(urllib.parse.urlsplit(route.request.url).query).get("url", [""])[0] if "chapter-" in url and url.endswith(".xhtml"): @@ -380,7 +380,7 @@ class ReaderRefactorTest(unittest.TestCase): "get: () => new Promise((resolve) => setTimeout(() => resolve(null), 300)),", "get: () => Promise.resolve({foliateSection: 10, foliateOffset: 0, foliateTocIndex: 9}),", ) - self.page.route("**/static/reader-store.js", lambda route: route.fulfill(content_type="text/javascript", body=store)) + self.page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(content_type="text/javascript", body=store)) def instrument(route): response = route.fetch() body = response.text().replace( @@ -388,7 +388,7 @@ class ReaderRefactorTest(unittest.TestCase): "async function createFoliateSection(section, index) { (window.__openedSections ||= []).push(index);", ) route.fulfill(response=response, body=body) - self.page.route("**/static/reader.js*", instrument) + self.page.route("**/static/reader/formats/reader-foliate-format.mjs*", instrument) self.serve(support.epub_with_many_chapters(), "application/epub+zip") self.open(self.reader_url("epub")) self.assertEqual(self.page.evaluate("window.__openedSections[0]"), 10) @@ -1023,7 +1023,7 @@ class ReaderRefactorTest(unittest.TestCase): }""" ) route.fulfill(response=response, body=script) - self.page.route("**/static/reader.js*", instrument) + self.page.route("**/static/reader/core/reader-lifecycle.mjs*", instrument) module = support.PDF_MODULE.replace("numPages: 30", "numPages: 2").replace( "getPage: () => Promise.resolve(page)", """getPage: () => Promise.resolve({ ...page, getTextContent() { return Promise.resolve({ items: [{ str: 'needle '.repeat(6000), hasEOL: false }] }); } })""") @@ -1053,7 +1053,7 @@ class ReaderRefactorTest(unittest.TestCase): window.__failSearchPage = false; throw new Error('temporary page load'); }""")) - self.page.route("**/static/reader.js*", instrument) + self.page.route("**/static/reader/pdf/reader-pdf-search.mjs*", instrument) module = support.PDF_MODULE.replace("numPages: 30", "numPages: 3").replace( "getPage: () => Promise.resolve(page)", """getPage: () => Promise.resolve({ ...page, getTextContent() { return Promise.resolve({ items: [{ str: 'needle '.repeat(60), hasEOL: false }] }); } })""") @@ -1150,8 +1150,8 @@ class ReaderRefactorTest(unittest.TestCase): response = route.fetch() route.fulfill(response=response, body=response.text().replace( "target = matches[0]?.target;", "target = matches[0]?.target; window.__fallbackDone = true;")) - self.page.route("**/static/reader-dom-search.mjs*", hold_fallback) - self.page.route("**/static/reader.js*", finish_fallback) + self.page.route("**/static/reader/search/reader-dom-search.mjs*", hold_fallback) + self.page.route("**/static/reader/search/reader-search-backends.mjs*", finish_fallback) self.serve(support.epub_with_many_chapters(3), "application/epub+zip") self.open(self.reader_url("epub")) self.search("正文") @@ -1605,7 +1605,7 @@ class ReaderRefactorTest(unittest.TestCase): self.page.get_by_text('失效旧链接', exact=True).click() self.assertEqual(self.page.locator('.reader-chapter-link-error').count(), 0) mapped = self.page.evaluate("""async () => { - const {chapterTextMap} = await import('/static/reader-chapter-search.mjs'); + const {chapterTextMap} = await import('/static/reader/search/reader-chapter-search.mjs'); return chapterTextMap([document.querySelector('.reader-epub-chapter[data-chapter="1"]')]).text; }""") self.assertNotIn('无法定位', mapped) @@ -1957,7 +1957,7 @@ class ReaderRefactorTest(unittest.TestCase): url = self.reader_url(name="remote-history") source = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)["url"][0] peer = self.context.new_page() - store_path = urllib.parse.urlsplit(url).path.replace("reader.html", "reader-store.js") + store_path = urllib.parse.urlsplit(url).path.replace("reader.html", "reader/core/reader-store.js") peer.route("**/store-peer", lambda route: route.fulfill(content_type="text/html", body=f'')) peer.goto(self.origin + "/store-peer") for event_type in ("history-remove", "history-clear"): @@ -2092,7 +2092,7 @@ class ReaderRefactorTest(unittest.TestCase): self.assertIn(needle, script) route.fulfill(response=response, body=script.replace(needle, needle + ' window.__sampleVisits = (window.__sampleVisits || 0) + 1;')) - self.page.route('**/static/reader.js*', instrument) + self.page.route('**/static/reader/formats/reader-text-format.mjs*', instrument) self.page.add_init_script('''const nativeFetch = window.fetch.bind(window), Decoder = window.TextDecoder; window.__previewDecoders = 0; window.TextDecoder = class extends Decoder { @@ -2267,7 +2267,7 @@ class ReaderRefactorTest(unittest.TestCase): route.fulfill(response=response, body=script.replace(needle, "await new Promise(resolve => { window.__releaseActivation = resolve; }); " + needle + " window.__activationSettled = true;")) - self.page.route("**/static/reader-dom-search.mjs*", hold_activation) + self.page.route("**/static/reader/search/reader-dom-search.mjs*", hold_activation) self.serve("oldword " * 70 + "freshword") self.open(self.reader_url()) self.search("oldword") @@ -2469,11 +2469,15 @@ class ReaderRefactorTest(unittest.TestCase): def instrument(route): response = route.fetch() script = response.text() - self.assertIn('repairHtmlContrast(frame);', script) - route.fulfill(response=response, body=script.replace('repairHtmlContrast(frame);', '') + ''' + self.assertIn('readerContext.repairHtmlContrast(frame);', script) + route.fulfill(response=response, body=script.replace('readerContext.repairHtmlContrast(frame);', '')) + self.page.route('**/static/reader/formats/reader-html-format.mjs*', instrument) + def expose(route): + response = route.fetch() + route.fulfill(response=response, body=response.text() + ''' window.__repairContrast = () => repairHtmlContrast(htmlFrame); ''') - self.page.route('**/static/reader.js*', instrument) + self.page.route('**/static/reader.js*', expose) def test_html_contrast_reuses_transparent_ancestors_and_refreshes_between_passes(self): self.expose_html_contrast() @@ -2777,7 +2781,7 @@ class ReaderRefactorTest(unittest.TestCase): self.assertIn(needle, script) route.fulfill(response=response, body=script.replace(needle, "window.__breakChapterWorker = () => worker.onerror({ preventDefault() {} }); " + needle)) - self.page.route("**/static/reader-chapter-search.mjs*", expose_worker_failure) + self.page.route("**/static/reader/search/reader-chapter-search.mjs*", expose_worker_failure) self.serve_chapter_search() self.search("needle") self.page.evaluate("window.__breakChapterWorker()") diff --git a/tests/test_reader_regression.js b/tests/test_reader_regression.js index 9b79ceca66934eac99d3d93a6045caf2512ab279..5e78e46addd3031b30346c8b1c0f209e28b4d7df 100644 --- a/tests/test_reader_regression.js +++ b/tests/test_reader_regression.js @@ -3,15 +3,15 @@ const path = require('path') const assert = require('assert') const vm = require('vm') const { TextEncoder } = require('util') -const { assertCode } = require('./reader_source_contract') +const { assertCode, readReaderSource } = require('./reader_source_contract') const root = path.resolve(__dirname, '..') -const reader = fs.readFileSync(path.join(root, 'static/reader.js'), 'utf8') -const contract = fs.readFileSync(path.join(root, 'static/reader-contract.js'), 'utf8') +const reader = readReaderSource(root) +const contract = fs.readFileSync(path.join(root, 'static/reader/core/reader-contract.js'), 'utf8') const sandbox = { self: {}, TextEncoder, URLSearchParams, URL } vm.runInNewContext(contract, sandbox) const pdfText = {} -vm.runInNewContext(fs.readFileSync(path.join(root, 'static/reader-pdf-text.js'), 'utf8').replace(/^export /gm, ''), pdfText) +vm.runInNewContext(fs.readFileSync(path.join(root, 'static/reader/pdf/reader-pdf-text.js'), 'utf8').replace(/^export /gm, ''), pdfText) for (const [raw, expected] of [ ['', ''], ['hello world', 'hello world'], ['a\n b', 'a\n b'], ['\u624b \u673a', '\u624b\u673a'], ['\u624b\u3002\u673a', '\u624b\u3002\u673a'], diff --git a/tests/test_reader_request_manager.js b/tests/test_reader_request_manager.js index 2100eeb8327fbc5b2066814a532a5d2fa614d5da..5b4a0905f1bbd694b2e8c15befd62023ddb7b82d 100644 --- a/tests/test_reader_request_manager.js +++ b/tests/test_reader_request_manager.js @@ -24,7 +24,7 @@ function loadManager(fetchImpl) { }; sandbox.self = sandbox; vm.runInNewContext( - fs.readFileSync(path.join(__dirname, "../static/reader-request-manager.js"), "utf8"), + fs.readFileSync(path.join(__dirname, "../static/reader/core/reader-request-manager.js"), "utf8"), sandbox ); return { manager: sandbox.VoiceOfMLReaderRequests.createReaderRequestManager(), timers }; diff --git a/tests/test_reader_runtime.js b/tests/test_reader_runtime.js index d1903946b57f14378d578e95023839c553047213..4afd92d4cbf61b3a449d0bd826308f7e44405147 100644 --- a/tests/test_reader_runtime.js +++ b/tests/test_reader_runtime.js @@ -5,7 +5,7 @@ const vm = require("vm"); const sandbox = { self: {}, setTimeout, clearTimeout, requestAnimationFrame: (callback) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout }; sandbox.self = sandbox; -for (const file of ["reader-contract.js", "reader-runtime.js", "reader-format-adapters.js"]) vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static", file), "utf8"), sandbox); +for (const file of ["reader/core/reader-contract.js", "reader/core/reader-runtime.js", "reader/formats/reader-format-adapters.js"]) vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static", file), "utf8"), sandbox); async function main() { const events = []; diff --git a/tests/test_reader_scroll_anchor.js b/tests/test_reader_scroll_anchor.js index b91a702b2da1f4c2b8e7edf6c95e96efa3a62118..10616bd928664ebd44eb962f7e8abdc46c509520 100644 --- a/tests/test_reader_scroll_anchor.js +++ b/tests/test_reader_scroll_anchor.js @@ -6,7 +6,7 @@ const vm = require("vm"); function loadManager(options) { const sandbox = { self: {} }; sandbox.self = sandbox; - vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader-scroll-anchor.js"), "utf8"), sandbox); + vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader/ui/reader-scroll-anchor.js"), "utf8"), sandbox); return sandbox.VoiceOfMLReaderScroll.createScrollAnchorManager(options); } diff --git a/tests/test_reader_section_virtualizer.js b/tests/test_reader_section_virtualizer.js index 49db19ba6edf86675728ea3a15e4417988d32130..a0df3e6379aba0d134c62625a32af84038bee594 100644 --- a/tests/test_reader_section_virtualizer.js +++ b/tests/test_reader_section_virtualizer.js @@ -6,7 +6,7 @@ const vm = require("vm"); function loadVirtualizer(options) { const sandbox = { self: {} }; sandbox.self = sandbox; - vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader-section-virtualizer.js"), "utf8"), sandbox); + vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader/formats/reader-section-virtualizer.js"), "utf8"), sandbox); return sandbox.VoiceOfMLReaderVirtual.createSectionVirtualizer(options); } diff --git a/tests/test_reader_security.js b/tests/test_reader_security.js index 4dc42c73c6daccdf57494e91ba4b84ce06451e84..b8bc68863966214dd4efef3ee802b449dee24d0b 100644 --- a/tests/test_reader_security.js +++ b/tests/test_reader_security.js @@ -5,7 +5,7 @@ const vm = require("vm"); const { deflateRawSync } = require("zlib"); const sandbox = { self: {}, TextDecoder, Uint8Array, DataView }; sandbox.self = sandbox; -vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader-security.js"), "utf8"), sandbox); +vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader/core/reader-security.js"), "utf8"), sandbox); const security = sandbox.VoiceOfMLReaderSecurity; function crc32(bytes) { diff --git a/tests/test_reader_store_channel.js b/tests/test_reader_store_channel.js index fbb702e231622c794048d37020d172b11018dd14..86e59c3e7332b4c2f8b7ae27ecc0e96e7318b958 100644 --- a/tests/test_reader_store_channel.js +++ b/tests/test_reader_store_channel.js @@ -1,7 +1,7 @@ const assert = require("assert"); const fs = require("fs"); const vm = require("vm"); -const source = fs.readFileSync("static/reader-store.js", "utf8"), channels = new Set(); +const source = fs.readFileSync("static/reader/core/reader-store.js", "utf8"), channels = new Set(); class BroadcastChannel { constructor(name) { this.name = name; this.closed = false; channels.add(this); } postMessage(data) { for (const channel of channels) if (channel !== this && !channel.closed && channel.name === this.name) channel.onmessage?.({ data }); } close() { this.closed = true; channels.delete(this); } } function loadStore() { const sandbox = { self: {}, BroadcastChannel }; sandbox.self = sandbox; vm.runInNewContext(source, sandbox); return sandbox.VoiceOfMLReaderStore; } const first = loadStore(), second = loadStore(), received = []; second.subscribe((change) => received.push(change)); const sender = new BroadcastChannel("voiceofml-reader-store-v1"); sender.postMessage({ type: "bookmark", url: "book", id: "one" }); assert.deepStrictEqual(JSON.parse(JSON.stringify(received)), [{ type: "bookmark", url: "book", id: "one", remote: true }]); second.dispose(); sender.postMessage({ type: "history", url: "book" }); assert.strictEqual(received.length, 1); first.dispose(); sender.close(); console.log("reader store channel contracts passed"); diff --git a/tests/test_reader_style.py b/tests/test_reader_style.py index ce367feb71d9a3b89f8ba4991c79e8d54ff2f660..3389585e384e74eccdbb978b03d9bd54b9f53dfd 100644 --- a/tests/test_reader_style.py +++ b/tests/test_reader_style.py @@ -39,7 +39,7 @@ class ReaderStyleTests(unittest.TestCase): self.addCleanup(context.close) self.page = context.new_page() self.page.add_init_script("localStorage.setItem('theme', 'dark')") - self.page.route("**/static/reader-store.js", lambda route: route.fulfill( + self.page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill( content_type="text/javascript", body=STORE_SCRIPT )) self.page.route("**/api/reader-content**", lambda route: route.fulfill( diff --git a/tests/test_reader_v3_catalog.py b/tests/test_reader_v3_catalog.py index 36502b8b6318fe076d65943df5206d4fadae2257..7e9e6dc31f8b8a640c18892530260ec759f3c6d4 100644 --- a/tests/test_reader_v3_catalog.py +++ b/tests/test_reader_v3_catalog.py @@ -7,8 +7,8 @@ import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, patch -import app -import reader_v3_catalog as v3 +from backend import app +from backend import reader_v3_catalog as v3 def fixture(): diff --git a/tests/test_reader_v3_layout_samples.py b/tests/test_reader_v3_layout_samples.py index 1798d261566e393ca950116c88162a3a9115a0ad..69c1280a98de1911180bbdb63c70c08083eaf590 100644 --- a/tests/test_reader_v3_layout_samples.py +++ b/tests/test_reader_v3_layout_samples.py @@ -108,10 +108,10 @@ class V3LayoutSamplesTests(unittest.TestCase): try: page = context.new_page() number = sample['page'] - store_script = (ROOT / 'static/reader-store.js').read_text() + ( + store_script = (ROOT / 'static/reader/core/reader-store.js').read_text() + ( '\nwindow.VoiceOfMLReaderStore=Object.freeze({...window.VoiceOfMLReaderStore,get:async url=>' '({schema:3,url,page:' + str(number) + ',pageCount:' + str(self.fixture['manifest']['page_count']) + ',pageOffset:0})});') - page.route('**/static/reader-store.js*', lambda route: route.fulfill(content_type='text/javascript', body=store_script)) + page.route('**/static/reader/core/reader-store.js*', lambda route: route.fulfill(content_type='text/javascript', body=store_script)) if PAGES: page.route('https://voiceofml-search.hf.space/api/reader-bucket-resource?**', lambda route: route.fulfill(response=route.fetch(url=self.origin + '/api/reader-bucket-resource?' + urlparse(route.request.url).query))) diff --git a/tests/test_reader_visual.py b/tests/test_reader_visual.py index 5ea67a54f47c42592e80daf765c3be3ca8f59061..061896b299072b7f36699eb5634c946ce17c0a6b 100644 --- a/tests/test_reader_visual.py +++ b/tests/test_reader_visual.py @@ -34,7 +34,7 @@ class ReaderVisualTests(unittest.TestCase): with self.subTest(viewport=name, theme=theme): context = self.browser.new_context(viewport={"width": width, "height": height}, color_scheme=theme) page = context.new_page(); page.add_init_script(f"localStorage.setItem('theme', '{theme}')") - page.route("**/static/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) + page.route("**/static/reader/core/reader-store.js", lambda route: route.fulfill(status=200, content_type="text/javascript", body=STORE_SCRIPT)) page.route("**/api/reader-content**", lambda route: route.fulfill(status=200, content_type="text/plain", body=("Reader visual fixture\n" * 80).encode())) page.goto(f"{self.origin}/static/reader.html?url={source}&ext=txt&title=Visual", wait_until="domcontentloaded") page.locator(".reader-text").wait_for(); page.add_style_tag(content="*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}") diff --git a/tests/test_reader_warming.js b/tests/test_reader_warming.js index 2c60e53e0c0d6f33c5dcdd9726c72bdcba101075..30224d194f55165e092865ec0b534fede487268c 100644 --- a/tests/test_reader_warming.js +++ b/tests/test_reader_warming.js @@ -8,16 +8,20 @@ const tick = () => new Promise(resolve => setImmediate(resolve)); function fixture() { let now = 100000; const timers = new Map(), calls = [], stored = new Map(), events = {}; - const context = {URL, Map, Set, Promise, AbortController, DOMException, encodeURIComponent, - VoiceOfMLReaderResources: require('../static/reader-resources.js'), + const subscribe = (name, callback) => { + const previous = events[name]; + events[name] = (...args) => { previous?.(...args); callback(...args); }; + }; + const context = {URL, Map, Set, Promise, AbortController, AbortSignal, DOMException, encodeURIComponent, + VoiceOfMLReaderResources: {...require('../static/reader-resources.js'), shellAssets:()=>[], engineAssets:()=>[]}, Date: {now: () => now}, API_BASE: 'https://api.test', location:{origin:'https://site.test'}, - navigator:{onLine:true}, document:{hidden:false,createElement:()=>({}),head:{appendChild(){}}, - addEventListener:(name,fn)=>events[name]=fn}, - window:{addEventListener:(name,fn)=>events[name]=fn}, + navigator:{onLine:true}, document:{hidden:false,createElement:()=>({addEventListener(){},remove(){}}),head:{appendChild(){}}, + addEventListener:subscribe}, + window:{addEventListener:subscribe}, sessionStorage:{get length(){return stored.size;},key:i=>[...stored.keys()][i], getItem:key=>stored.get(key)||null,removeItem:key=>stored.delete(key), setItem:(key,value)=>stored.set(key,value)},warmConnection(){}, - setTimeout:fn=>{const id={};timers.set(id,fn);return id;},clearTimeout:id=>timers.delete(id), + setTimeout:(fn,delay)=>{if(delay===1500){fn();return null;}const id={};timers.set(id,fn);return id;},clearTimeout:id=>timers.delete(id), fetch:(url,options={})=>new Promise((resolve,reject)=>{ calls.push({url,options,resolve,reject}); options.signal?.addEventListener('abort',()=>reject(new DOMException('cancelled','AbortError'))); diff --git a/tests/test_search.py b/tests/test_search.py index f52737a19df624e96e8fb598d52c22881a2b3395..854de4bc65183e002891bf0283e70a3e732f01d9 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -6,7 +6,7 @@ from unittest import mock IMPORT_ERROR = None try: - import app + from backend import app except ModuleNotFoundError as exc: # pragma: no cover - environment dependent app = None IMPORT_ERROR = exc diff --git a/tests/test_search_behavior.py b/tests/test_search_behavior.py index be4dfdc9e282dd23777fe89cf8eddc5a231c1f56..e295f7781615be21d206e840962941f6dce62b3f 100644 --- a/tests/test_search_behavior.py +++ b/tests/test_search_behavior.py @@ -9,7 +9,7 @@ import unittest IMPORT_ERROR = None try: - import app + from backend import app except ModuleNotFoundError as exc: # pragma: no cover app = None IMPORT_ERROR = exc @@ -159,7 +159,7 @@ class SearchBehaviorTests(unittest.TestCase): def test_pathological_wildcards_finish_without_blocking_search(self): result = subprocess.run([sys.executable, "-B", "-c", """ -import app +from backend import app app.records = [{'Repo':'VoiceOfML/A','File':'a'*120,'Folder':[], 'Extension':'txt','Size':1}] app.build_indexes() assert app.search('*a'*100+'b', exact=True)['total'] == 0 diff --git a/tests/test_search_execution.py b/tests/test_search_execution.py index 2cf1f5056adcc4fdfa1a89a6ab228d797c554fdb..cb7943ab5e10fb629914f494332486497a7779e0 100644 --- a/tests/test_search_execution.py +++ b/tests/test_search_execution.py @@ -1,7 +1,7 @@ import asyncio import threading import unittest -from search_execution import SearchBusy, SearchExecutor +from backend.search_execution import SearchBusy, SearchExecutor class SearchExecutionTests(unittest.IsolatedAsyncioTestCase): @@ -40,7 +40,7 @@ class SearchExecutionTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(seen, ["foreground", "background"]) async def test_slow_search_does_not_block_http_ping_and_busy_requests_retry(self): - import app + from backend import app import httpx from unittest.mock import patch, AsyncMock started, release = threading.Event(), threading.Event() diff --git a/tests/test_search_order_cache.py b/tests/test_search_order_cache.py index 554863d5ee1af8a7ec29c70509b9c6a12d8dc193..20790346bb090d46d7f111929e144a231f812a56 100644 --- a/tests/test_search_order_cache.py +++ b/tests/test_search_order_cache.py @@ -8,7 +8,7 @@ import weakref from pathlib import Path import unittest from unittest import mock -import app +from backend import app from tests import test_search_behavior as behavior diff --git a/tests/test_security.py b/tests/test_security.py index f3e14ea8d90a407d00b8dda0bc78e02f1531e1fe..e2b006a579e3af6e0c17c3895d3b480a429314da 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -4,7 +4,7 @@ import unittest IMPORT_ERROR = None try: - import app + from backend import app except ModuleNotFoundError as exc: # pragma: no cover app = None IMPORT_ERROR = exc diff --git a/tests/test_service_worker.js b/tests/test_service_worker.js index 5cc92f3bf9bbe9e71363f5ff58981aab0a010090..59fb0a45c1c9c9446613d018783d1b538f31d4e6 100644 --- a/tests/test_service_worker.js +++ b/tests/test_service_worker.js @@ -69,7 +69,7 @@ function harness({cached, fetchError, openError, put, fetch: network} = {}) { await Promise.all(h.lifetimes); assert.strictEqual(h.writes.length, 0); } - for (const path of ['/static/reader-chapter-search.mjs', '/static/reader-chapter-search-worker.mjs', '/static/reader-pdf-text.js', '/static/pdf-worker-wrapper.mjs']) { + for (const path of ['/static/reader/search/reader-chapter-search.mjs', '/static/reader/search/workers/reader-chapter-search-worker.mjs', '/static/reader/pdf/reader-pdf-text.js', '/static/reader/pdf/pdf-worker-wrapper.mjs']) { const h = harness({cached: response('old')}); assert.strictEqual(await h.dispatch(path), h.fresh); await Promise.all(h.lifetimes); diff --git a/tests/test_service_worker_pruning.js b/tests/test_service_worker_pruning.js index 447828aeb46ffc2398bf8a7d39b7682175ebf123..8953dea1968422a752250f604b623a842c4a39ba 100644 --- a/tests/test_service_worker_pruning.js +++ b/tests/test_service_worker_pruning.js @@ -9,6 +9,8 @@ const assert = require('assert'); const entries = new Map(paths.map(url => [url, {ok: true}])); entries.set('https://example.test/data.json', {ok: true}); entries.set('https://example.test/static/app.js', {ok: true}); + entries.set('https://example.test/static/reader/pdf/reader-pdf-search.mjs', {ok: true}); + entries.set('https://example.test/static/reader/pdf/reader-pdf-search.555555555555.mjs', {ok: true}); const cache = { keys: async () => [...entries.keys()].map(url => ({url})), match: async request => entries.get(request.url), @@ -31,6 +33,10 @@ const assert = require('assert'); assert.ok(entries.has(paths[0])); assert.ok(!entries.has(paths[1])); assert.strictEqual(entries.size, 2); + let nestedResponse; + listeners.fetch({request: {url: 'https://example.test/static/reader/pdf/reader-pdf-search.mjs', method: 'GET'}, + waitUntil() {}, respondWith: promise => { nestedResponse = promise; }}); + assert.strictEqual(nestedResponse, undefined); let response; listeners.fetch({request: {url: 'https://example.test/static/app.555555555555.js', method: 'GET'}, waitUntil: promise => { pending = promise; }, respondWith: promise => { response = promise; }}); diff --git a/tests/test_static_contract.py b/tests/test_static_contract.py index b293582548a5cc6908586a5290cb1f1408b20a73..913d9736ee8cc7bbdc41f7d6e9b61f71f9adf70e 100644 --- a/tests/test_static_contract.py +++ b/tests/test_static_contract.py @@ -6,6 +6,7 @@ import re import subprocess import unittest from pathlib import Path +from tests.reader_sources import read_reader_source ROOT = Path(__file__).parents[1] @@ -41,6 +42,8 @@ class StaticFrontendContractTests(unittest.TestCase): for line in docker.splitlines(): if line.startswith("COPY ") and line.endswith(" ."): copied.update(line.split()[1:-1]) + self.assertIn("COPY backend/ backend/", docker) + copied.update(str(file.relative_to(ROOT)) for file in (ROOT / "backend").rglob("*.py")) pending = ["app.py"] visited = set() while pending: @@ -53,7 +56,7 @@ class StaticFrontendContractTests(unittest.TestCase): modules = [node.module] if isinstance(node, ast.ImportFrom) else ( [item.name for item in node.names] if isinstance(node, ast.Import) else []) for module in modules: - filename = (module or "").split(".")[0] + ".py" + filename = (module or "").replace(".", "/") + ".py" if (ROOT / filename).is_file(): pending.append(filename) @@ -75,13 +78,13 @@ class StaticFrontendContractTests(unittest.TestCase): self.assertIn("backToTopPending = false", self.js) def test_pdf_reader_uses_demand_ranges_without_full_file_streaming(self): - reader = (ROOT / "static/reader.js").read_text(encoding="utf-8") + reader = read_reader_source(ROOT) self.assertIn("disableAutoFetch: true", reader) self.assertIn("disableStream: true", reader) def test_pdf_ocr_text_fallback_and_first_page_prefetch(self): - reader = (ROOT / "static/reader.js").read_text(encoding="utf-8") - text = (ROOT / "static/reader-pdf-text.js").read_text(encoding="utf-8") + reader = read_reader_source(ROOT) + text = (ROOT / "static/reader/pdf/reader-pdf-text.js").read_text(encoding="utf-8") css = (ROOT / "static/reader.css").read_text(encoding="utf-8") self.assertIn("populateOcrTextLayer(layer, payload.blocks, payload.layout)", reader) self.assertIn("const pdfOcrPagePromises = new Map()", reader) @@ -108,13 +111,13 @@ class StaticFrontendContractTests(unittest.TestCase): self.assertNotRegex(self.js, r"随机打开文章|暂无可读文章") def test_reader_uses_original_files_and_lazy_pdf_canvas_rendering(self): - contract = (ROOT / "static/reader-contract.js").read_text(encoding="utf-8") - reader = (ROOT / "static/reader.js").read_text(encoding="utf-8") + contract = (ROOT / "static/reader/core/reader-contract.js").read_text(encoding="utf-8") + reader = read_reader_source(ROOT) reader_css = (ROOT / "static/reader.css").read_text(encoding="utf-8") reader_html = (ROOT / "static/reader.html").read_text(encoding="utf-8") - reader_store = (ROOT / "static/reader-store.js").read_text(encoding="utf-8") - search_controller = (ROOT / "static/reader-search-controller.mjs").read_text(encoding="utf-8") - self.assertIn('import { createReaderSearchController } from "./reader-search-controller.mjs"', reader) + reader_store = (ROOT / "static/reader/core/reader-store.js").read_text(encoding="utf-8") + search_controller = (ROOT / "static/reader/search/reader-search-controller.mjs").read_text(encoding="utf-8") + self.assertIn('import { createReaderSearchController } from "./reader/search/reader-search-controller.mjs"', reader) self.assertEqual(len(re.findall(r'dataset\.panelView\s*=\s*"full-search"', search_controller)), 1) self.assertNotIn("window.find", reader) for value in ('id="full-search-input"', 'id="full-search-status"', 'id="full-search-results"', 'id="full-search-prev"', 'id="full-search-next"'): @@ -131,7 +134,7 @@ class StaticFrontendContractTests(unittest.TestCase): 'docx': 'docx', 'html': 'html', 'htm': 'html'} evaluated = subprocess.run( ['node', '-e', 'const vm = require("vm"), fs = require("fs"); const context = { self: {} }; ' - 'vm.runInNewContext(fs.readFileSync("static/reader-contract.js", "utf8"), context); ' + 'vm.runInNewContext(fs.readFileSync("static/reader/core/reader-contract.js", "utf8"), context); ' 'const api = context.self.VoiceOfMLReader; ' 'console.log(JSON.stringify(Object.fromEntries(JSON.parse(process.argv[1]).map(ext => [ext, api.capability(ext).mode]))));', json.dumps(list(modes))], cwd=ROOT, check=True, capture_output=True, text=True) @@ -151,15 +154,15 @@ class StaticFrontendContractTests(unittest.TestCase): self.assertIn("IntersectionObserver", reader) self.assertIn("Map.prototype.getOrInsertComputed", reader) self.assertIn("Math.sumPrecise", reader) - worker_wrapper = (ROOT / "static/pdf-worker-wrapper.mjs").read_text(encoding="utf-8") + worker_wrapper = (ROOT / "static/reader/pdf/pdf-worker-wrapper.mjs").read_text(encoding="utf-8") self.assertIn("Map.prototype.getOrInsertComputed", worker_wrapper) self.assertIn("Math.sumPrecise", worker_wrapper) - self.assertIn('import(VoiceOfMLReaderResources.vendorUrl("pdfWorker", "./"))', worker_wrapper) + self.assertIn('import(VoiceOfMLReaderResources.vendorUrl("pdfWorker", "../../"))', worker_wrapper) self.assertIn("pendingMessages", worker_wrapper) assert_source_contains(self, 'document.createElement("canvas")', reader) self.assertNotIn("previewUrls", reader) self.assertNotIn("ReaderPreview", contract) - self.assertIn("/static/reader-contract.js", reader_html) + self.assertIn("/static/reader/core/reader-contract.js", reader_html) self.assertIn('id="download"', reader_html) self.assertRegex(reader_html, r'id="download"[^>]*target="_blank"[^>]*rel="noopener noreferrer"') self.assertNotIn('id="ocr"', reader_html) @@ -236,12 +239,12 @@ class StaticFrontendContractTests(unittest.TestCase): self.assertIn("actionBtn.dataset.readerUrl = getReaderLink(applyReaderAsset(", self.js) self.assertIn("navigateToReader(actionBtn.dataset.readerUrl)", self.js) self.assertIn("navigateToReader(readUrl)", self.js) - navigation = (ROOT / "static/reader-navigation.js").read_text(encoding="utf-8") + navigation = (ROOT / "static/search/reader-navigation.js").read_text(encoding="utf-8") self.assertIn('url.searchParams.set("return", returnUrl)', navigation) self.assertIn('sessionStorage.setItem("reader-return:" + token', navigation) self.assertIn('crypto.getRandomValues(new Uint32Array(4))', navigation) - self.assertIn('reader-navigation.js', self.html) - self.assertIn('reader-navigation.js', self.sw) + self.assertIn('search/reader-navigation.js', self.html) + self.assertIn('search/reader-navigation.js', self.sw) self.assertNotIn("openPendingWindow", self.js) assert_source_contains(self, 'new URL(returnUrl, location.origin)', reader) assert_source_contains(self, 'sessionStorage.removeItem(returnHistoryKey)', reader) @@ -250,7 +253,7 @@ class StaticFrontendContractTests(unittest.TestCase): assert_source_contains(self, 'location.assign("/")', reader) self.assertIn('requestId !== randomReaderRequestId || location.href !== returnUrl', self.js) self.assertIn("rec.ReaderExtension || rec.Extension", self.js) - backend = (ROOT / "app.py").read_text(encoding="utf-8") + backend = (ROOT / "backend/app.py").read_text(encoding="utf-8") self.assertIn('get_reader_assets(wait=not bool(reader_assets_cache.get("f")))', backend) self.assertIn("converted_reader_indices(assets, full_repo)", backend) self.assertIn("loadReaderAssets().then(refreshResultReaderActions)", self.js) @@ -328,7 +331,7 @@ class StaticFrontendContractTests(unittest.TestCase): self.assertIn('Content-Security-Policy', reader_html) self.assertRegex(reader_html, r"connect-src[^\"]*https://\*\.hf\.co") self.assertIn("script-src 'self'", reader_html) - vendor_script = (ROOT / "scripts/copy_reader_vendor.mjs").read_text(encoding="utf-8") + (ROOT / "static/reader-resources.js").read_text(encoding="utf-8") + vendor_script = (ROOT / "scripts/build/copy_reader_vendor.mjs").read_text(encoding="utf-8") + (ROOT / "static/reader-resources.js").read_text(encoding="utf-8") self.assertIn('pdfjs-dist@6.4.299', vendor_script) self.assertIn('standard_fonts/', vendor_script) self.assertIn('cmaps/', vendor_script) @@ -383,13 +386,14 @@ class StaticFrontendContractTests(unittest.TestCase): self.assertIn('const warmedReaderSources = new Set()', self.js) self.assertIn('warmedReaderSources.size >= 8', self.js) self.assertIn('method: readerId ? "GET" : "HEAD", cache: "no-store"', self.js) - reader = (ROOT / "static/reader.js").read_text(encoding="utf-8") - image_loader = re.search(r'^function loadImageDocument\([\s\S]*?^}', reader, re.M).group() - assert_source_contains(self, 'if (!fallback) { fallback = true; image.src = target; } else finish(new Error("image load failed"))', image_loader) - assert_source_contains(self, 'image.src = readerContentUrl(target)', image_loader) + reader = read_reader_source(ROOT) + image_loader = re.search(r'^async function loadImageDocument\([\s\S]*?^}', reader, re.M).group() + assert_source_contains(self, 'return await imageLoader.load(image, readerContentUrl(target), readerAbortController.signal, deadline)', image_loader) + assert_source_contains(self, 'if (error?.code === "READER_RESOURCE_LIMIT") throw error', image_loader) + assert_source_contains(self, 'return imageLoader.load(image, target, readerAbortController.signal, deadline)', image_loader) def test_pdf_image_stream_prioritizes_visible_pages_over_prefetch(self): - reader = (ROOT / "static/reader.js").read_text(encoding="utf-8") + reader = read_reader_source(ROOT) assert_source_contains(self, 'root: viewport, rootMargin: `${FOLIATE_PREFETCH_MARGIN}px`', reader) assert_source_contains(self, 'root: viewport, rootMargin: `${PDF_PREFETCH_MARGIN}px 0px`', reader) assert_source_contains(self, 'const visibleObserver = new IntersectionObserver', reader) @@ -415,7 +419,8 @@ class StaticFrontendContractTests(unittest.TestCase): self.assertIn("ARG ESBUILD_VERSION=0.28.2", dockerfile) self.assertIn("node /build/compose_app.mjs > /build/app-composed.js", dockerfile) self.assertIn("esbuild /build/app-composed.js --minify --target=es2020 --charset=utf8 --legal-comments=none", dockerfile) - self.assertIn('esbuild "static/$file" --minify', dockerfile) + self.assertIn('esbuild "static/$file" $flags --minify', dockerfile) + self.assertIn('.bundleArguments(process.argv[1])', dockerfile) self.assertIn('.buildFiles("hf")', dockerfile) self.assertIn("node /build/build_static_assets.mjs", dockerfile) self.assertIn("node /build/copy_reader_vendor.mjs /out/vendor", dockerfile) @@ -425,17 +430,17 @@ class StaticFrontendContractTests(unittest.TestCase): self.assertEqual(dockerfile.count("FROM node:"), 1) self.assertEqual(dockerfile.count("FROM python:"), 2) self.assertIn("FROM python:3.11.17-slim AS search-index-build", dockerfile) - self.assertIn("python scripts/build_search_index_native.py --output-dir /native", dockerfile) + self.assertIn("python scripts/build/build_search_index_native.py --output-dir /native", dockerfile) runtime = re.split(r'^FROM ', dockerfile, flags=re.M)[-1] self.assertTrue(runtime.startswith("python:3.11.17-slim\n")) self.assertIn("COPY --from=search-index-build /native/ /app/", runtime) self.assertNotRegex(runtime, r'(?m)^RUN .*\b(?:node|npm|esbuild|gcc)\b') def test_static_build_creates_content_hashed_asset_urls(self): - build_script = (ROOT / "scripts/build_static_assets.mjs").read_text(encoding="utf-8") + build_script = (ROOT / "scripts/build/build_static_assets.mjs").read_text(encoding="utf-8") self.assertIn('createHash("sha256")', build_script) self.assertIn('resources.buildFiles("hf")', build_script) - reader_store = (ROOT / "static/reader-store.js").read_text(encoding="utf-8") + reader_store = (ROOT / "static/reader/core/reader-store.js").read_text(encoding="utf-8") self.assertIn("SCHEMA_VERSION = 1", reader_store) self.assertIn("normalizeHistoryEntry", reader_store) self.assertIn("normalizeBookmarkEntry", reader_store) diff --git a/tests/test_upstream_http.py b/tests/test_upstream_http.py index 64ed32fdcd2056d03922e14ce2c90d64d16ace30..c23af36bfdee419b618f5e13b9700308b7cbafd2 100644 --- a/tests/test_upstream_http.py +++ b/tests/test_upstream_http.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, patch from urllib.parse import quote import aiohttp -import upstream_http as upstream +from backend import upstream_http as upstream class UpstreamHttpTests(unittest.IsolatedAsyncioTestCase):