vomebook commited on
Commit ·
4ef7354
1
Parent(s): 30d788e
refactor: organize project layout and Reader responsibilities
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .prettierrc.json +2 -1
- Dockerfile +11 -9
- app.py +0 -0
- backend/__init__.py +1 -0
- backend/app.py +0 -0
- data_formats.py → backend/data_formats.py +0 -0
- search_index_native.c → backend/native/search_index_native.c +0 -0
- proxy_streams.py → backend/proxy_streams.py +0 -0
- reader_assets.py → backend/reader_assets.py +1 -1
- reader_v3_catalog.py → backend/reader_v3_catalog.py +1 -1
- record_metadata.py → backend/record_metadata.py +0 -0
- search_execution.py → backend/search_execution.py +0 -0
- search_indexes.py → backend/search_indexes.py +0 -0
- upstream_http.py → backend/upstream_http.py +0 -0
- scripts/{benchmark_reader_open.py → benchmarks/benchmark_reader_open.py} +0 -0
- scripts/{benchmark_reader_stages.py → benchmarks/benchmark_reader_stages.py} +0 -0
- scripts/{reader-opening-media-samples.json → benchmarks/reader-opening-media-samples.json} +0 -0
- scripts/{reader-opening-samples.json → benchmarks/reader-opening-samples.json} +0 -0
- scripts/{build_search_index_native.py → build/build_search_index_native.py} +1 -1
- scripts/{build_static_assets.mjs → build/build_static_assets.mjs} +12 -4
- scripts/{check_reader_release.mjs → build/check_reader_release.mjs} +34 -26
- scripts/{compose_app.mjs → build/compose_app.mjs} +3 -3
- scripts/{copy_reader_vendor.mjs → build/copy_reader_vendor.mjs} +0 -0
- static/index.html +4 -4
- static/reader-resources.js +57 -34
- static/reader.html +4 -4
- static/reader.js +0 -0
- static/reader/core/reader-bindings.mjs +847 -0
- static/{reader-contract.js → reader/core/reader-contract.js} +0 -0
- static/reader/core/reader-diagnostics.mjs +22 -0
- static/reader/core/reader-document-loader.mjs +164 -0
- static/reader/core/reader-lifecycle.mjs +170 -0
- static/{reader-request-manager.js → reader/core/reader-request-manager.js} +0 -0
- static/{reader-runtime.js → reader/core/reader-runtime.js} +0 -0
- static/{reader-security.js → reader/core/reader-security.js} +0 -0
- static/reader/core/reader-source.mjs +335 -0
- static/{reader-store.js → reader/core/reader-store.js} +0 -0
- static/reader/formats/reader-chapter-format.mjs +450 -0
- static/{reader-chapter-repository.js → reader/formats/reader-chapter-repository.js} +0 -0
- static/reader/formats/reader-docx-format.mjs +129 -0
- static/reader/formats/reader-foliate-format.mjs +637 -0
- static/{reader-format-adapters.js → reader/formats/reader-format-adapters.js} +0 -0
- static/reader/formats/reader-format-controller.mjs +280 -0
- static/reader/formats/reader-html-format.mjs +322 -0
- static/reader/formats/reader-image-loader.mjs +59 -0
- static/reader/formats/reader-media-format.mjs +90 -0
- static/{reader-section-virtualizer.js → reader/formats/reader-section-virtualizer.js} +0 -0
- static/reader/formats/reader-text-format.mjs +196 -0
- static/{pdf-worker-wrapper.mjs → reader/pdf/pdf-worker-wrapper.mjs} +2 -2
- static/{reader-engine-preload.js → reader/pdf/reader-engine-preload.js} +1 -1
.prettierrc.json
CHANGED
|
@@ -3,7 +3,8 @@
|
|
| 3 |
{
|
| 4 |
"files": [
|
| 5 |
"static/reader*.{js,css,html}",
|
| 6 |
-
"
|
|
|
|
| 7 |
],
|
| 8 |
"options": { "printWidth": 100, "trailingComma": "none" }
|
| 9 |
}
|
|
|
|
| 3 |
{
|
| 4 |
"files": [
|
| 5 |
"static/reader*.{js,css,html}",
|
| 6 |
+
"static/reader/**/*.mjs",
|
| 7 |
+
"scripts/build/check_reader_release.mjs"
|
| 8 |
],
|
| 9 |
"options": { "printWidth": 100, "trailingComma": "none" }
|
| 10 |
}
|
Dockerfile
CHANGED
|
@@ -11,10 +11,10 @@ RUN npm install --global "esbuild@${ESBUILD_VERSION}" \
|
|
| 11 |
&& test "$(esbuild --version)" = "${ESBUILD_VERSION}"
|
| 12 |
|
| 13 |
COPY static/ /build/static/
|
| 14 |
-
COPY scripts/build_static_assets.mjs /build/build_static_assets.mjs
|
| 15 |
-
COPY scripts/copy_reader_vendor.mjs /build/copy_reader_vendor.mjs
|
| 16 |
-
COPY scripts/check_reader_release.mjs /build/check_reader_release.mjs
|
| 17 |
-
COPY scripts/compose_app.mjs /build/compose_app.mjs
|
| 18 |
|
| 19 |
RUN mkdir -p /out \
|
| 20 |
&& cp -a static/. /out/ \
|
|
@@ -22,7 +22,8 @@ RUN mkdir -p /out \
|
|
| 22 |
&& node /build/compose_app.mjs > /build/app-composed.js \
|
| 23 |
&& esbuild /build/app-composed.js --minify --target=es2020 --charset=utf8 --legal-comments=none --outfile=/out/app.js \
|
| 24 |
&& for file in $(node -e 'console.log(require("./static/reader-resources.js").buildFiles("hf").filter(f => f !== "app.js").join(" "))'); do \
|
| 25 |
-
|
|
|
|
| 26 |
done \
|
| 27 |
&& node /build/build_static_assets.mjs \
|
| 28 |
&& 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
|
|
| 34 |
WORKDIR /build
|
| 35 |
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev \
|
| 36 |
&& rm -rf /var/lib/apt/lists/*
|
| 37 |
-
COPY search_index_native.c .
|
| 38 |
-
COPY scripts/build_search_index_native.py scripts/build_search_index_native.py
|
| 39 |
-
RUN python scripts/build_search_index_native.py --output-dir /native
|
| 40 |
|
| 41 |
FROM python:3.11.17-slim
|
| 42 |
|
|
@@ -49,7 +50,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
| 49 |
COPY requirements.txt .
|
| 50 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 51 |
|
| 52 |
-
COPY app.py
|
|
|
|
| 53 |
COPY --from=search-index-build /native/ /app/
|
| 54 |
COPY --from=static-assets /out/ static/
|
| 55 |
COPY data/ data/
|
|
|
|
| 11 |
&& test "$(esbuild --version)" = "${ESBUILD_VERSION}"
|
| 12 |
|
| 13 |
COPY static/ /build/static/
|
| 14 |
+
COPY scripts/build/build_static_assets.mjs /build/build_static_assets.mjs
|
| 15 |
+
COPY scripts/build/copy_reader_vendor.mjs /build/copy_reader_vendor.mjs
|
| 16 |
+
COPY scripts/build/check_reader_release.mjs /build/check_reader_release.mjs
|
| 17 |
+
COPY scripts/build/compose_app.mjs /build/compose_app.mjs
|
| 18 |
|
| 19 |
RUN mkdir -p /out \
|
| 20 |
&& cp -a static/. /out/ \
|
|
|
|
| 22 |
&& node /build/compose_app.mjs > /build/app-composed.js \
|
| 23 |
&& esbuild /build/app-composed.js --minify --target=es2020 --charset=utf8 --legal-comments=none --outfile=/out/app.js \
|
| 24 |
&& for file in $(node -e 'console.log(require("./static/reader-resources.js").buildFiles("hf").filter(f => f !== "app.js").join(" "))'); do \
|
| 25 |
+
flags=$(node -e 'console.log(require("./static/reader-resources.js").bundleArguments(process.argv[1]).join(" "))' "$file"); \
|
| 26 |
+
esbuild "static/$file" $flags --minify --target=es2020 --charset=utf8 --legal-comments=none --outfile="/out/$file" || exit 1; \
|
| 27 |
done \
|
| 28 |
&& node /build/build_static_assets.mjs \
|
| 29 |
&& esbuild /out/sw.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/sw.min.js \
|
|
|
|
| 35 |
WORKDIR /build
|
| 36 |
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev \
|
| 37 |
&& rm -rf /var/lib/apt/lists/*
|
| 38 |
+
COPY backend/native/search_index_native.c backend/native/search_index_native.c
|
| 39 |
+
COPY scripts/build/build_search_index_native.py scripts/build/build_search_index_native.py
|
| 40 |
+
RUN python scripts/build/build_search_index_native.py --output-dir /native
|
| 41 |
|
| 42 |
FROM python:3.11.17-slim
|
| 43 |
|
|
|
|
| 50 |
COPY requirements.txt .
|
| 51 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 52 |
|
| 53 |
+
COPY app.py .
|
| 54 |
+
COPY backend/ backend/
|
| 55 |
COPY --from=search-index-build /native/ /app/
|
| 56 |
COPY --from=static-assets /out/ static/
|
| 57 |
COPY data/ data/
|
app.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Search service helpers; the FastAPI entry point remains app:app."""
|
backend/app.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data_formats.py → backend/data_formats.py
RENAMED
|
File without changes
|
search_index_native.c → backend/native/search_index_native.c
RENAMED
|
File without changes
|
proxy_streams.py → backend/proxy_streams.py
RENAMED
|
File without changes
|
reader_assets.py → backend/reader_assets.py
RENAMED
|
@@ -6,7 +6,7 @@ import json
|
|
| 6 |
import posixpath
|
| 7 |
import re
|
| 8 |
|
| 9 |
-
from upstream_http import READER_ASSET_PATH_RE
|
| 10 |
|
| 11 |
|
| 12 |
READER_ASSET_FILENAMES = {
|
|
|
|
| 6 |
import posixpath
|
| 7 |
import re
|
| 8 |
|
| 9 |
+
from backend.upstream_http import READER_ASSET_PATH_RE
|
| 10 |
|
| 11 |
|
| 12 |
READER_ASSET_FILENAMES = {
|
reader_v3_catalog.py → backend/reader_v3_catalog.py
RENAMED
|
@@ -22,7 +22,7 @@ def catalog_path(generation):
|
|
| 22 |
|
| 23 |
|
| 24 |
def decode_catalog(pointer, raw):
|
| 25 |
-
from reader_assets import READER_BUCKET_PATH_RE
|
| 26 |
if pointer.get("version") != 1 or pointer.get("kind") != "reader-reading-pointer":
|
| 27 |
raise ValueError("invalid v3 pointer")
|
| 28 |
generation = pointer.get("generation")
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def decode_catalog(pointer, raw):
|
| 25 |
+
from backend.reader_assets import READER_BUCKET_PATH_RE
|
| 26 |
if pointer.get("version") != 1 or pointer.get("kind") != "reader-reading-pointer":
|
| 27 |
raise ValueError("invalid v3 pointer")
|
| 28 |
generation = pointer.get("generation")
|
record_metadata.py → backend/record_metadata.py
RENAMED
|
File without changes
|
search_execution.py → backend/search_execution.py
RENAMED
|
File without changes
|
search_indexes.py → backend/search_indexes.py
RENAMED
|
File without changes
|
upstream_http.py → backend/upstream_http.py
RENAMED
|
File without changes
|
scripts/{benchmark_reader_open.py → benchmarks/benchmark_reader_open.py}
RENAMED
|
File without changes
|
scripts/{benchmark_reader_stages.py → benchmarks/benchmark_reader_stages.py}
RENAMED
|
File without changes
|
scripts/{reader-opening-media-samples.json → benchmarks/reader-opening-media-samples.json}
RENAMED
|
File without changes
|
scripts/{reader-opening-samples.json → benchmarks/reader-opening-samples.json}
RENAMED
|
File without changes
|
scripts/{build_search_index_native.py → build/build_search_index_native.py}
RENAMED
|
@@ -11,7 +11,7 @@ parser.add_argument("--output-dir", required=True)
|
|
| 11 |
args = parser.parse_args()
|
| 12 |
output = Path(args.output_dir)
|
| 13 |
output.mkdir(parents=True, exist_ok=True)
|
| 14 |
-
source = Path(__file__).resolve().parents[
|
| 15 |
target = output / ("_search_index_native" + sysconfig.get_config_var("EXT_SUFFIX"))
|
| 16 |
compiler = shlex.split(os.environ.get("CC") or sysconfig.get_config_var("CC") or "cc")
|
| 17 |
subprocess.run([*compiler, "-O3", "-shared", "-fPIC", "-Wall", "-Wextra",
|
|
|
|
| 11 |
args = parser.parse_args()
|
| 12 |
output = Path(args.output_dir)
|
| 13 |
output.mkdir(parents=True, exist_ok=True)
|
| 14 |
+
source = Path(__file__).resolve().parents[2] / "backend/native/search_index_native.c"
|
| 15 |
target = output / ("_search_index_native" + sysconfig.get_config_var("EXT_SUFFIX"))
|
| 16 |
compiler = shlex.split(os.environ.get("CC") or sysconfig.get_config_var("CC") or "cc")
|
| 17 |
subprocess.run([*compiler, "-O3", "-shared", "-fPIC", "-Wall", "-Wextra",
|
scripts/{build_static_assets.mjs → build/build_static_assets.mjs}
RENAMED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { createHash } from "node:crypto";
|
| 2 |
import { renameSync, readFileSync, writeFileSync } from "node:fs";
|
| 3 |
-
import { extname, join } from "node:path";
|
| 4 |
import { createRequire } from "node:module";
|
| 5 |
import { stripBundledScripts } from "./compose_app.mjs";
|
| 6 |
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
|
|
| 8 |
const outputDir = process.env.STATIC_OUTPUT_DIR || "/out";
|
| 9 |
const replacements = new Map();
|
| 10 |
const currentVendorAssets = new Set(Object.values(resources.vendors).map(vendor => "/static/" + vendor.path));
|
| 11 |
-
function rewrite(text) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
text = text.replace(/VoiceOfMLReaderResources\.engineAssets\(([\w$]+),\s*"(\/static\/)"\)/g,
|
| 13 |
(_, 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}]||[])`);
|
| 14 |
text = text.replace(/VoiceOfMLReaderResources\.(shellAssets|runtimePaths)\("(\/static\/)"\)/g,
|
|
@@ -23,7 +31,7 @@ for (const filename of resources.buildFiles("hf")) {
|
|
| 23 |
let text = readFileSync(source, "utf8");
|
| 24 |
for (const match of text.matchAll(/(?:\/static\/|\.\/)vendor\/([A-Za-z0-9._-]+\.[0-9a-f]{12}\.(?:js|mjs|css))/g))
|
| 25 |
currentVendorAssets.add("/static/vendor/" + match[1]);
|
| 26 |
-
text = rewrite(text);
|
| 27 |
writeFileSync(source, text);
|
| 28 |
}
|
| 29 |
const content = readFileSync(source);
|
|
@@ -40,7 +48,7 @@ for (const filename of ["index.html", "reader.html", "sw.js"]) {
|
|
| 40 |
const path = join(outputDir, filename);
|
| 41 |
let content = readFileSync(path, "utf8");
|
| 42 |
if (filename === "index.html") content = stripBundledScripts(content);
|
| 43 |
-
content = rewrite(content);
|
| 44 |
if (filename === "sw.js") content = content.replace(/CURRENT_HASHED_ASSETS\s*=\s*\[\]/,
|
| 45 |
"CURRENT_HASHED_ASSETS=" + JSON.stringify([...replacements.values()].filter(url => url.startsWith("/")).concat([...currentVendorAssets])));
|
| 46 |
writeFileSync(path, content);
|
|
|
|
| 1 |
import { createHash } from "node:crypto";
|
| 2 |
import { renameSync, readFileSync, writeFileSync } from "node:fs";
|
| 3 |
+
import { dirname, extname, join, relative } from "node:path";
|
| 4 |
import { createRequire } from "node:module";
|
| 5 |
import { stripBundledScripts } from "./compose_app.mjs";
|
| 6 |
const resources = createRequire(import.meta.url)(join(process.cwd(), "static/reader-resources.js"));
|
|
|
|
| 8 |
const outputDir = process.env.STATIC_OUTPUT_DIR || "/out";
|
| 9 |
const replacements = new Map();
|
| 10 |
const currentVendorAssets = new Set(Object.values(resources.vendors).map(vendor => "/static/" + vendor.path));
|
| 11 |
+
function rewrite(text, filename) {
|
| 12 |
+
text = text.replace(/(["'])(\.\.?\/[^"'\s]+)\1/g, (match, quote, url) => {
|
| 13 |
+
const target = join(dirname(filename), url);
|
| 14 |
+
const versioned = replacements.get(`/static/${target}`);
|
| 15 |
+
if (!versioned) return match;
|
| 16 |
+
let pinned = relative(dirname(filename), versioned.slice("/static/".length));
|
| 17 |
+
if (!pinned.startsWith(".")) pinned = "./" + pinned;
|
| 18 |
+
return quote + pinned + quote;
|
| 19 |
+
});
|
| 20 |
text = text.replace(/VoiceOfMLReaderResources\.engineAssets\(([\w$]+),\s*"(\/static\/)"\)/g,
|
| 21 |
(_, 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}]||[])`);
|
| 22 |
text = text.replace(/VoiceOfMLReaderResources\.(shellAssets|runtimePaths)\("(\/static\/)"\)/g,
|
|
|
|
| 31 |
let text = readFileSync(source, "utf8");
|
| 32 |
for (const match of text.matchAll(/(?:\/static\/|\.\/)vendor\/([A-Za-z0-9._-]+\.[0-9a-f]{12}\.(?:js|mjs|css))/g))
|
| 33 |
currentVendorAssets.add("/static/vendor/" + match[1]);
|
| 34 |
+
text = rewrite(text, filename);
|
| 35 |
writeFileSync(source, text);
|
| 36 |
}
|
| 37 |
const content = readFileSync(source);
|
|
|
|
| 48 |
const path = join(outputDir, filename);
|
| 49 |
let content = readFileSync(path, "utf8");
|
| 50 |
if (filename === "index.html") content = stripBundledScripts(content);
|
| 51 |
+
content = rewrite(content, filename);
|
| 52 |
if (filename === "sw.js") content = content.replace(/CURRENT_HASHED_ASSETS\s*=\s*\[\]/,
|
| 53 |
"CURRENT_HASHED_ASSETS=" + JSON.stringify([...replacements.values()].filter(url => url.startsWith("/")).concat([...currentVendorAssets])));
|
| 54 |
writeFileSync(path, content);
|
scripts/{check_reader_release.mjs → build/check_reader_release.mjs}
RENAMED
|
@@ -39,10 +39,11 @@ function staticRoot(root, artifact) {
|
|
| 39 |
}
|
| 40 |
|
| 41 |
function checkBehavior(root) {
|
| 42 |
-
const reader =
|
| 43 |
-
|
|
|
|
| 44 |
assert(
|
| 45 |
-
|
| 46 |
`Reader dependency missing from inventory: ${dependency}`
|
| 47 |
);
|
| 48 |
}
|
|
@@ -118,11 +119,12 @@ function checkAssets(output, hashed = false) {
|
|
| 118 |
function currentAsset(output, filename) {
|
| 119 |
if (!resources.buildFiles("github").includes(filename)) return filename;
|
| 120 |
assert(!fs.existsSync(path.join(output, filename)), `obsolete asset alias: ${filename}`);
|
| 121 |
-
const
|
| 122 |
-
const
|
|
|
|
| 123 |
file.endsWith(extension) && /^[0-9a-f]{12}$/.test(file.slice(stem.length + 1, -extension.length)));
|
| 124 |
assert(matches.length === 1, `expected one current asset: ${filename}`);
|
| 125 |
-
return matches[0];
|
| 126 |
}
|
| 127 |
|
| 128 |
function checkProject(root, artifact) {
|
|
@@ -138,7 +140,7 @@ function checkProject(root, artifact) {
|
|
| 138 |
const output = staticRoot(root, artifact);
|
| 139 |
checkAssets(output, !!artifact);
|
| 140 |
if (artifact) {
|
| 141 |
-
const project = fs.existsSync(path.join(root, "
|
| 142 |
for (const file of resources.buildFiles(project)) currentAsset(output, file);
|
| 143 |
const shellPath = fs.existsSync(path.join(output, "index.html"))
|
| 144 |
? path.join(output, "index.html") : path.join(output, "../index.html");
|
|
@@ -167,24 +169,30 @@ function checkProject(root, artifact) {
|
|
| 167 |
function crossCheck(githubRoot, hfRoot) {
|
| 168 |
const exact = [
|
| 169 |
"reader-resources.js",
|
| 170 |
-
"reader-store.js",
|
| 171 |
-
"reader-pdf-text.js",
|
| 172 |
-
"reader-pdf-book-search-worker.mjs",
|
| 173 |
-
"reader-pdf-book-search.mjs",
|
| 174 |
-
"search-session.js",
|
| 175 |
-
"download-controller.js",
|
| 176 |
-
"reader-navigation.js",
|
| 177 |
"reader.css",
|
| 178 |
-
"reader-request-manager.js",
|
| 179 |
-
"reader-chapter-repository.js",
|
| 180 |
-
"reader-scroll-anchor.js",
|
| 181 |
-
"reader-section-virtualizer.js",
|
| 182 |
-
"reader-runtime.js",
|
| 183 |
-
"reader-format-adapters.js",
|
| 184 |
-
"reader-security.js",
|
| 185 |
-
"reader-search-controller.mjs",
|
| 186 |
-
"reader-dom-search.mjs",
|
| 187 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
];
|
| 189 |
for (const file of exact) {
|
| 190 |
assert(
|
|
@@ -194,8 +202,8 @@ function crossCheck(githubRoot, hfRoot) {
|
|
| 194 |
}
|
| 195 |
for (const file of ["compose_app.mjs", "copy_reader_vendor.mjs", "check_reader_release.mjs"]) {
|
| 196 |
assert(
|
| 197 |
-
hash(path.join(githubRoot, "scripts", file)) === hash(path.join(hfRoot, "scripts", file)),
|
| 198 |
-
`cross-project drift: scripts/${file}`
|
| 199 |
);
|
| 200 |
}
|
| 201 |
for (const root of [githubRoot, hfRoot]) checkBehavior(root);
|
|
|
|
| 39 |
}
|
| 40 |
|
| 41 |
function checkBehavior(root) {
|
| 42 |
+
const reader = ["reader.js", ...resources.readerComponentFiles].map(file =>
|
| 43 |
+
fs.readFileSync(path.join(root, "static", file), "utf8")).join("\n").replace(/\breaderContext\./g, "");
|
| 44 |
+
for (const [, dependency] of reader.matchAll(/(?:\/static\/)([^"'\s]+\.(?:js|mjs))/g)) {
|
| 45 |
assert(
|
| 46 |
+
required.includes(dependency),
|
| 47 |
`Reader dependency missing from inventory: ${dependency}`
|
| 48 |
);
|
| 49 |
}
|
|
|
|
| 119 |
function currentAsset(output, filename) {
|
| 120 |
if (!resources.buildFiles("github").includes(filename)) return filename;
|
| 121 |
assert(!fs.existsSync(path.join(output, filename)), `obsolete asset alias: ${filename}`);
|
| 122 |
+
const directory = path.dirname(filename), basename = path.basename(filename);
|
| 123 |
+
const extension = path.extname(basename), stem = basename.slice(0, -extension.length);
|
| 124 |
+
const matches = fs.readdirSync(path.join(output, directory)).filter(file => file.startsWith(stem + ".") &&
|
| 125 |
file.endsWith(extension) && /^[0-9a-f]{12}$/.test(file.slice(stem.length + 1, -extension.length)));
|
| 126 |
assert(matches.length === 1, `expected one current asset: ${filename}`);
|
| 127 |
+
return path.join(directory, matches[0]);
|
| 128 |
}
|
| 129 |
|
| 130 |
function checkProject(root, artifact) {
|
|
|
|
| 140 |
const output = staticRoot(root, artifact);
|
| 141 |
checkAssets(output, !!artifact);
|
| 142 |
if (artifact) {
|
| 143 |
+
const project = args.project || (fs.existsSync(path.join(root, "Dockerfile")) ? "hf" : "github");
|
| 144 |
for (const file of resources.buildFiles(project)) currentAsset(output, file);
|
| 145 |
const shellPath = fs.existsSync(path.join(output, "index.html"))
|
| 146 |
? path.join(output, "index.html") : path.join(output, "../index.html");
|
|
|
|
| 169 |
function crossCheck(githubRoot, hfRoot) {
|
| 170 |
const exact = [
|
| 171 |
"reader-resources.js",
|
| 172 |
+
"reader/core/reader-store.js",
|
| 173 |
+
"reader/pdf/reader-pdf-text.js",
|
| 174 |
+
"reader/search/workers/reader-pdf-book-search-worker.mjs",
|
| 175 |
+
"reader/search/reader-pdf-book-search.mjs",
|
| 176 |
+
"search/search-session.js",
|
| 177 |
+
"search/download-controller.js",
|
| 178 |
+
"search/reader-navigation.js",
|
| 179 |
"reader.css",
|
| 180 |
+
"reader/core/reader-request-manager.js",
|
| 181 |
+
"reader/formats/reader-chapter-repository.js",
|
| 182 |
+
"reader/ui/reader-scroll-anchor.js",
|
| 183 |
+
"reader/formats/reader-section-virtualizer.js",
|
| 184 |
+
"reader/core/reader-runtime.js",
|
| 185 |
+
"reader/formats/reader-format-adapters.js",
|
| 186 |
+
"reader/core/reader-security.js",
|
| 187 |
+
"reader/search/reader-search-controller.mjs",
|
| 188 |
+
"reader/search/reader-dom-search.mjs",
|
| 189 |
+
"reader/formats/reader-image-loader.mjs",
|
| 190 |
+
"reader/reader-components.mjs",
|
| 191 |
+
"reader/ui/reader-document-styles.mjs",
|
| 192 |
+
"reader/core/reader-diagnostics.mjs", "reader/core/reader-lifecycle.mjs", "reader/ui/reader-bookmarks.mjs",
|
| 193 |
+
"reader/formats/reader-media-format.mjs", "reader/core/reader-document-loader.mjs", "reader/formats/reader-html-format.mjs",
|
| 194 |
+
"reader/formats/reader-text-format.mjs", "reader/formats/reader-chapter-format.mjs", "reader/formats/reader-docx-format.mjs",
|
| 195 |
+
"reader/pdf/pdf-worker-wrapper.mjs"
|
| 196 |
];
|
| 197 |
for (const file of exact) {
|
| 198 |
assert(
|
|
|
|
| 202 |
}
|
| 203 |
for (const file of ["compose_app.mjs", "copy_reader_vendor.mjs", "check_reader_release.mjs"]) {
|
| 204 |
assert(
|
| 205 |
+
hash(path.join(githubRoot, "scripts/build", file)) === hash(path.join(hfRoot, "scripts/build", file)),
|
| 206 |
+
`cross-project drift: scripts/build/${file}`
|
| 207 |
);
|
| 208 |
}
|
| 209 |
for (const root of [githubRoot, hfRoot]) checkBehavior(root);
|
scripts/{compose_app.mjs → build/compose_app.mjs}
RENAMED
|
@@ -5,12 +5,12 @@ import { pathToFileURL } from "node:url";
|
|
| 5 |
// One ordered dependency list owns the production app and its shell scripts.
|
| 6 |
// Keep classic-script globals for the host; each dependency owns its own scope.
|
| 7 |
export const APP_DEPENDENCIES = Object.freeze([
|
| 8 |
-
"reader-resources.js", "reader-contract.js", "reader-navigation.js",
|
| 9 |
-
"search-session.js", "download-controller.js"
|
| 10 |
]);
|
| 11 |
|
| 12 |
export function stripBundledScripts(html) {
|
| 13 |
-
return html.replace(/<script\s+src="(?:\/?(?:search\/)?static\/)([^"
|
| 14 |
(tag, filename) => APP_DEPENDENCIES.includes(filename) ? "" : tag);
|
| 15 |
}
|
| 16 |
|
|
|
|
| 5 |
// One ordered dependency list owns the production app and its shell scripts.
|
| 6 |
// Keep classic-script globals for the host; each dependency owns its own scope.
|
| 7 |
export const APP_DEPENDENCIES = Object.freeze([
|
| 8 |
+
"reader-resources.js", "reader/core/reader-contract.js", "search/reader-navigation.js",
|
| 9 |
+
"search/search-session.js", "search/download-controller.js"
|
| 10 |
]);
|
| 11 |
|
| 12 |
export function stripBundledScripts(html) {
|
| 13 |
+
return html.replace(/<script\s+src="(?:\/?(?:search\/)?static\/)([^"\s]+)"\s*>\s*<\/script>\s*/g,
|
| 14 |
(tag, filename) => APP_DEPENDENCIES.includes(filename) ? "" : tag);
|
| 15 |
}
|
| 16 |
|
scripts/{copy_reader_vendor.mjs → build/copy_reader_vendor.mjs}
RENAMED
|
File without changes
|
static/index.html
CHANGED
|
@@ -287,10 +287,10 @@ if ("serviceWorker" in navigator) {
|
|
| 287 |
</script>
|
| 288 |
|
| 289 |
<script src="/static/reader-resources.js"></script>
|
| 290 |
-
<script src="/static/reader-contract.js"></script>
|
| 291 |
-
<script src="/static/reader-navigation.js"></script>
|
| 292 |
-
<script src="/static/search-session.js"></script>
|
| 293 |
-
<script src="/static/download-controller.js"></script>
|
| 294 |
<script src="/static/app.js"></script>
|
| 295 |
</body>
|
| 296 |
</html>
|
|
|
|
| 287 |
</script>
|
| 288 |
|
| 289 |
<script src="/static/reader-resources.js"></script>
|
| 290 |
+
<script src="/static/reader/core/reader-contract.js"></script>
|
| 291 |
+
<script src="/static/search/reader-navigation.js"></script>
|
| 292 |
+
<script src="/static/search/search-session.js"></script>
|
| 293 |
+
<script src="/static/search/download-controller.js"></script>
|
| 294 |
<script src="/static/app.js"></script>
|
| 295 |
</body>
|
| 296 |
</html>
|
static/reader-resources.js
CHANGED
|
@@ -85,41 +85,55 @@
|
|
| 85 |
}
|
| 86 |
};
|
| 87 |
for (const vendor of Object.values(foliateVendors)) Object.freeze(vendor);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
const readerFiles = Object.freeze([
|
| 89 |
"reader-resources.js",
|
| 90 |
-
"reader-engine-preload.js",
|
| 91 |
-
"reader-chapter-search-worker.mjs",
|
| 92 |
-
"reader-chapter-search.mjs",
|
| 93 |
-
"reader-contract.js",
|
| 94 |
-
"reader-navigation.js",
|
| 95 |
-
"reader-store.js",
|
| 96 |
-
"reader-request-manager.js",
|
| 97 |
-
"reader-chapter-repository.js",
|
| 98 |
-
"reader-scroll-anchor.js",
|
| 99 |
-
"reader-section-virtualizer.js",
|
| 100 |
-
"reader-runtime.js",
|
| 101 |
-
"reader-format-adapters.js",
|
| 102 |
-
"reader-security.js",
|
| 103 |
-
"reader-search-controller.mjs",
|
| 104 |
-
"reader-dom-search.mjs",
|
| 105 |
-
"reader-
|
| 106 |
-
"reader-
|
| 107 |
-
"reader-
|
| 108 |
-
"reader-
|
| 109 |
-
"reader-pdf-book-search.mjs",
|
| 110 |
-
"pdf-
|
| 111 |
-
"reader
|
| 112 |
-
"reader-pdf-
|
|
|
|
| 113 |
"reader.css",
|
|
|
|
|
|
|
| 114 |
"reader.js"
|
| 115 |
]);
|
| 116 |
const lazyFiles = new Set([
|
| 117 |
-
|
| 118 |
-
"reader-
|
| 119 |
-
"reader-
|
| 120 |
-
"reader-
|
| 121 |
-
"reader-
|
| 122 |
-
"pdf-
|
|
|
|
|
|
|
| 123 |
]);
|
| 124 |
const vendorUrl = (name, base) => base + vendors[name].path;
|
| 125 |
const runtimePaths = (base) => readerFiles.map((file) => base + file);
|
|
@@ -129,7 +143,7 @@
|
|
| 129 |
if (extension === "pdf")
|
| 130 |
return [
|
| 131 |
vendorUrl("pdf", base),
|
| 132 |
-
base + "pdf-worker-wrapper.mjs",
|
| 133 |
vendorUrl("pdfWorker", base)
|
| 134 |
];
|
| 135 |
if (["epub", "mobi", "azw", "azw3", "fb2", "fbz"].includes(extension))
|
|
@@ -142,24 +156,33 @@
|
|
| 142 |
function buildFiles(project) {
|
| 143 |
return [
|
| 144 |
...readerFiles,
|
| 145 |
-
"search-session.js",
|
| 146 |
-
"download-controller.js",
|
| 147 |
"style.css",
|
| 148 |
-
...(project === "github" ? ["index-worker.js"] : []),
|
| 149 |
"app.js"
|
| 150 |
];
|
| 151 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
const resources = Object.freeze({
|
| 153 |
vendors: Object.freeze(vendors),
|
| 154 |
foliateVendors: Object.freeze(foliateVendors),
|
| 155 |
foliateRevision: "78914aef4466eb960965702401634c2cb348e9b1",
|
| 156 |
pdfArchive,
|
| 157 |
readerFiles,
|
|
|
|
| 158 |
vendorUrl,
|
| 159 |
runtimePaths,
|
| 160 |
shellAssets,
|
| 161 |
engineAssets,
|
| 162 |
-
buildFiles
|
|
|
|
| 163 |
});
|
| 164 |
if (typeof module !== "undefined" && module.exports) module.exports = resources;
|
| 165 |
else root.VoiceOfMLReaderResources = resources;
|
|
|
|
| 85 |
}
|
| 86 |
};
|
| 87 |
for (const vendor of Object.values(foliateVendors)) Object.freeze(vendor);
|
| 88 |
+
const readerComponentFiles = Object.freeze([
|
| 89 |
+
"reader/core/reader-diagnostics.mjs", "reader/core/reader-lifecycle.mjs", "reader/core/reader-source.mjs", "reader/ui/reader-ui.mjs",
|
| 90 |
+
"reader/ui/reader-toc.mjs", "reader/search/reader-search-backends.mjs", "reader/pdf/reader-pdf-scheduler.mjs", "reader/ui/reader-navigation.mjs",
|
| 91 |
+
"reader/formats/reader-foliate-format.mjs", "reader/ui/reader-progress.mjs", "reader/ui/reader-bookmarks.mjs", "reader/formats/reader-media-format.mjs",
|
| 92 |
+
"reader/core/reader-document-loader.mjs", "reader/pdf/reader-pdf-page-window.mjs", "reader/pdf/reader-pdf-text-runtime.mjs",
|
| 93 |
+
"reader/formats/reader-html-format.mjs", "reader/pdf/reader-pdf-renderer.mjs", "reader/formats/reader-text-format.mjs",
|
| 94 |
+
"reader/formats/reader-chapter-format.mjs", "reader/formats/reader-docx-format.mjs", "reader/formats/reader-format-controller.mjs",
|
| 95 |
+
"reader/pdf/reader-pdf-engine.mjs", "reader/pdf/reader-pdf-search.mjs", "reader/core/reader-bindings.mjs", "reader/ui/reader-document-styles.mjs"
|
| 96 |
+
]);
|
| 97 |
const readerFiles = Object.freeze([
|
| 98 |
"reader-resources.js",
|
| 99 |
+
"reader/pdf/reader-engine-preload.js",
|
| 100 |
+
"reader/search/workers/reader-chapter-search-worker.mjs",
|
| 101 |
+
"reader/search/reader-chapter-search.mjs",
|
| 102 |
+
"reader/core/reader-contract.js",
|
| 103 |
+
"search/reader-navigation.js",
|
| 104 |
+
"reader/core/reader-store.js",
|
| 105 |
+
"reader/core/reader-request-manager.js",
|
| 106 |
+
"reader/formats/reader-chapter-repository.js",
|
| 107 |
+
"reader/ui/reader-scroll-anchor.js",
|
| 108 |
+
"reader/formats/reader-section-virtualizer.js",
|
| 109 |
+
"reader/core/reader-runtime.js",
|
| 110 |
+
"reader/formats/reader-format-adapters.js",
|
| 111 |
+
"reader/core/reader-security.js",
|
| 112 |
+
"reader/search/reader-search-controller.mjs",
|
| 113 |
+
"reader/search/reader-dom-search.mjs",
|
| 114 |
+
"reader/formats/reader-image-loader.mjs",
|
| 115 |
+
"reader/pdf/reader-pdf-text.js",
|
| 116 |
+
"reader/pdf/reader-v3.mjs",
|
| 117 |
+
"reader/search/reader-book-text.js",
|
| 118 |
+
"reader/search/workers/reader-pdf-book-search-worker.mjs",
|
| 119 |
+
"reader/search/reader-pdf-book-search.mjs",
|
| 120 |
+
"reader/pdf/pdf-worker-wrapper.mjs",
|
| 121 |
+
"reader/pdf/reader-pdf-network.mjs",
|
| 122 |
+
"reader/pdf/reader-pdf-text-store.mjs",
|
| 123 |
"reader.css",
|
| 124 |
+
...readerComponentFiles,
|
| 125 |
+
"reader/reader-components.mjs",
|
| 126 |
"reader.js"
|
| 127 |
]);
|
| 128 |
const lazyFiles = new Set([
|
| 129 |
+
...readerComponentFiles,
|
| 130 |
+
"reader/reader-components.mjs",
|
| 131 |
+
"reader/pdf/reader-pdf-text-store.mjs",
|
| 132 |
+
"reader/search/workers/reader-chapter-search-worker.mjs",
|
| 133 |
+
"reader/search/reader-chapter-search.mjs",
|
| 134 |
+
"reader/search/workers/reader-pdf-book-search-worker.mjs",
|
| 135 |
+
"reader/search/reader-pdf-book-search.mjs",
|
| 136 |
+
"reader/pdf/pdf-worker-wrapper.mjs"
|
| 137 |
]);
|
| 138 |
const vendorUrl = (name, base) => base + vendors[name].path;
|
| 139 |
const runtimePaths = (base) => readerFiles.map((file) => base + file);
|
|
|
|
| 143 |
if (extension === "pdf")
|
| 144 |
return [
|
| 145 |
vendorUrl("pdf", base),
|
| 146 |
+
base + "reader/pdf/pdf-worker-wrapper.mjs",
|
| 147 |
vendorUrl("pdfWorker", base)
|
| 148 |
];
|
| 149 |
if (["epub", "mobi", "azw", "azw3", "fb2", "fbz"].includes(extension))
|
|
|
|
| 156 |
function buildFiles(project) {
|
| 157 |
return [
|
| 158 |
...readerFiles,
|
| 159 |
+
"search/search-session.js",
|
| 160 |
+
"search/download-controller.js",
|
| 161 |
"style.css",
|
| 162 |
+
...(project === "github" ? ["search/index-worker.js"] : []),
|
| 163 |
"app.js"
|
| 164 |
];
|
| 165 |
}
|
| 166 |
+
function bundleArguments(filename) {
|
| 167 |
+
if (!["reader.js", "reader/reader-components.mjs"].includes(filename)) return [];
|
| 168 |
+
const external = readerFiles.filter(file => !readerComponentFiles.includes(file) &&
|
| 169 |
+
!["reader.js", "reader/reader-components.mjs"].includes(file) && /\.(?:js|mjs)$/.test(file));
|
| 170 |
+
return ["--bundle", "--format=esm", "--external:/static/*", "--external:/search/static/*",
|
| 171 |
+
...external.map(file => "--external:./" + file)];
|
| 172 |
+
}
|
| 173 |
const resources = Object.freeze({
|
| 174 |
vendors: Object.freeze(vendors),
|
| 175 |
foliateVendors: Object.freeze(foliateVendors),
|
| 176 |
foliateRevision: "78914aef4466eb960965702401634c2cb348e9b1",
|
| 177 |
pdfArchive,
|
| 178 |
readerFiles,
|
| 179 |
+
readerComponentFiles,
|
| 180 |
vendorUrl,
|
| 181 |
runtimePaths,
|
| 182 |
shellAssets,
|
| 183 |
engineAssets,
|
| 184 |
+
buildFiles,
|
| 185 |
+
bundleArguments
|
| 186 |
});
|
| 187 |
if (typeof module !== "undefined" && module.exports) module.exports = resources;
|
| 188 |
else root.VoiceOfMLReaderResources = resources;
|
static/reader.html
CHANGED
|
@@ -51,11 +51,11 @@
|
|
| 51 |
<link rel="preconnect" href="https://huggingface.co" />
|
| 52 |
<link rel="preconnect" href="https://voiceofml-search.hf.space" />
|
| 53 |
<link rel="stylesheet" href="/static/reader.css" />
|
| 54 |
-
<script defer src="/static/reader-security.js"></script>
|
| 55 |
<script defer src="/static/reader-resources.js"></script>
|
| 56 |
-
<script defer src="/static/reader-engine-preload.js"></script>
|
| 57 |
-
<script defer src="/static/reader-contract.js"></script>
|
| 58 |
-
<script defer src="/static/reader-store.js"></script>
|
| 59 |
</head>
|
| 60 |
<body>
|
| 61 |
<header class="reader-toolbar">
|
|
|
|
| 51 |
<link rel="preconnect" href="https://huggingface.co" />
|
| 52 |
<link rel="preconnect" href="https://voiceofml-search.hf.space" />
|
| 53 |
<link rel="stylesheet" href="/static/reader.css" />
|
| 54 |
+
<script defer src="/static/reader/core/reader-security.js"></script>
|
| 55 |
<script defer src="/static/reader-resources.js"></script>
|
| 56 |
+
<script defer src="/static/reader/pdf/reader-engine-preload.js"></script>
|
| 57 |
+
<script defer src="/static/reader/core/reader-contract.js"></script>
|
| 58 |
+
<script defer src="/static/reader/core/reader-store.js"></script>
|
| 59 |
</head>
|
| 60 |
<body>
|
| 61 |
<header class="reader-toolbar">
|
static/reader.js
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/reader/core/reader-bindings.mjs
ADDED
|
@@ -0,0 +1,847 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function createReaderBindings(readerContext) {
|
| 2 |
+
function trackStartupOwners() {
|
| 3 |
+
readerContext.readerRuntime.track(readerContext.readerRequestManager);
|
| 4 |
+
readerContext.readerRuntime.track(readerContext.formatAdapters);
|
| 5 |
+
readerContext.readerRuntime.track(window.__VOICE_PDF_PRELOAD__);
|
| 6 |
+
readerContext.readerRuntime.track(window.__VOICE_READER_CHAPTER_PRELOAD__);
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
function trackWorkCancellation() {
|
| 10 |
+
readerContext.readerRuntime.track(readerContext.stopReaderWork);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function trackEarlySourceResolution() {
|
| 14 |
+
readerContext.trackReaderResource(() => window.__VOICE_READER_RESOLVE__?.dispose());
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function publishDiagnosticsAndInstallCompatibility() {
|
| 18 |
+
window.VoiceOfMLReaderPdfDiagnostics = readerContext.pdfDiagnostics;
|
| 19 |
+
readerContext.trackReaderResource(() => {
|
| 20 |
+
readerContext.pdfPageTimings.length = 0;
|
| 21 |
+
if (window.VoiceOfMLReaderPdfDiagnostics === readerContext.pdfDiagnostics) delete window.VoiceOfMLReaderPdfDiagnostics;
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
// Polyfills for vendor engines (pdf.js, foliate): neither helper has a direct
|
| 25 |
+
// caller in first-party code, but both are required by pinned vendor bundles.
|
| 26 |
+
if (!Map.prototype.getOrInsertComputed) {
|
| 27 |
+
Map.prototype.getOrInsertComputed = function (key, callback) {
|
| 28 |
+
if (this.has(key)) return this.get(key);
|
| 29 |
+
const value = callback(key);
|
| 30 |
+
this.set(key, value);
|
| 31 |
+
return value;
|
| 32 |
+
};
|
| 33 |
+
}
|
| 34 |
+
if (!Math.sumPrecise) {
|
| 35 |
+
Math.sumPrecise = function (values) {
|
| 36 |
+
let sum = 0;
|
| 37 |
+
let correction = 0;
|
| 38 |
+
for (const value of values) {
|
| 39 |
+
const next = sum + value;
|
| 40 |
+
correction += Math.abs(sum) >= Math.abs(value) ? sum - next + value : value - next + sum;
|
| 41 |
+
sum = next;
|
| 42 |
+
}
|
| 43 |
+
return sum + correction;
|
| 44 |
+
};
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function readSelectedSourceSession() {
|
| 49 |
+
try {
|
| 50 |
+
readerContext.localReaderData = JSON.parse(sessionStorage.getItem(`reader-source:${readerContext.readerId}`) || "null");
|
| 51 |
+
if (readerContext.localReaderData) sessionStorage.removeItem(`reader-source:${readerContext.readerId}`);
|
| 52 |
+
} catch (_) {}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function normalizeConvertedPdfMode() {
|
| 56 |
+
if (
|
| 57 |
+
readerContext.extension === "pdf" &&
|
| 58 |
+
/\/api\/reader-bucket-resource\?/.test(readerContext.sourceUrl) &&
|
| 59 |
+
new URL(readerContext.sourceUrl, location.href).searchParams.get("path")?.endsWith("page-manifest.json")
|
| 60 |
+
) {
|
| 61 |
+
readerContext.extension = "pdf-pages";
|
| 62 |
+
readerContext.capability = readerContext.readerRuntime.negotiate(VoiceOfMLReader.capability(readerContext.extension));
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function applySourceSessionAndCachedMetadata() {
|
| 67 |
+
if (readerContext.localReaderData) {
|
| 68 |
+
if (readerContext.localReaderData.title) readerContext.params.set("title", readerContext.localReaderData.title);
|
| 69 |
+
if (readerContext.localReaderData.extension) readerContext.params.set("ext", readerContext.localReaderData.extension);
|
| 70 |
+
if (readerContext.localReaderData.repo)
|
| 71 |
+
readerContext.params.set("path", [readerContext.localReaderData.repo, ...(readerContext.localReaderData.folder || [])].join("/"));
|
| 72 |
+
}
|
| 73 |
+
try {
|
| 74 |
+
const cached = sessionStorage.getItem(`reader-resolve:${readerContext.readerId}`);
|
| 75 |
+
if (cached) {
|
| 76 |
+
readerContext.cachedReaderData = JSON.parse(cached);
|
| 77 |
+
readerContext.resolvedReaderData = readerContext.cachedReaderData;
|
| 78 |
+
sessionStorage.removeItem(`reader-resolve:${readerContext.readerId}`);
|
| 79 |
+
}
|
| 80 |
+
} catch (_) {}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function publishSourceAndSubscribeToStore() {
|
| 84 |
+
readerContext.readerRuntime.update("source", {
|
| 85 |
+
id: readerContext.readerId,
|
| 86 |
+
url: readerContext.sourceUrl,
|
| 87 |
+
contentUrl: readerContext.contentUrl,
|
| 88 |
+
downloadUrl: readerContext.downloadUrl,
|
| 89 |
+
extension: readerContext.extension,
|
| 90 |
+
metadata: readerContext.resolvedReaderData
|
| 91 |
+
});
|
| 92 |
+
readerContext.readerRuntime.events.on("phase", ({ phase }) => {
|
| 93 |
+
document.documentElement.dataset.readerPhase = phase;
|
| 94 |
+
});
|
| 95 |
+
readerContext.readerRuntime.track(
|
| 96 |
+
VoiceOfMLReaderStore.subscribe?.((change) => {
|
| 97 |
+
if (!change?.remote || readerContext.readerLifecycle.disposed) return;
|
| 98 |
+
if (
|
| 99 |
+
change.type === "history-clear" ||
|
| 100 |
+
(change.type === "history-remove" && change.url === readerContext.sourceUrl)
|
| 101 |
+
) {
|
| 102 |
+
readerContext.historySuppressed = true;
|
| 103 |
+
readerContext.readerRuntime.cancel(readerContext.saveTimer);
|
| 104 |
+
readerContext.saveTimer = readerContext.saveDeadline = 0;
|
| 105 |
+
}
|
| 106 |
+
if (
|
| 107 |
+
["history", "history-clear", "history-remove"].includes(change.type) &&
|
| 108 |
+
!document.querySelector("#history-panel").hidden
|
| 109 |
+
)
|
| 110 |
+
readerContext.renderHistory();
|
| 111 |
+
if (
|
| 112 |
+
["bookmark", "bookmark-remove"].includes(change.type) &&
|
| 113 |
+
(readerContext.panelState.showingAllBookmarks || !change.url || change.url === readerContext.sourceUrl) &&
|
| 114 |
+
!document.querySelector("#bookmarks-panel").hidden
|
| 115 |
+
)
|
| 116 |
+
readerContext.renderBookmarks();
|
| 117 |
+
})
|
| 118 |
+
);
|
| 119 |
+
|
| 120 |
+
readerContext.setReaderPhase("startup");
|
| 121 |
+
window.fetchFile = async (url) => {
|
| 122 |
+
const requestUrl = String(url) === readerContext.sourceUrl ? readerContext.contentUrl : url;
|
| 123 |
+
const response = await readerContext.readerRequestManager.request(requestUrl, readerContext.READER_PROXY_TIMEOUT_MS);
|
| 124 |
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
| 125 |
+
const fileName = new URL(url, location.href).pathname.split("/").pop() || "book";
|
| 126 |
+
return new File(
|
| 127 |
+
[
|
| 128 |
+
await VoiceOfMLReaderSecurity.readBytes(
|
| 129 |
+
response,
|
| 130 |
+
VoiceOfMLReaderSecurity.LIMITS.archiveCompressedBytes
|
| 131 |
+
)
|
| 132 |
+
],
|
| 133 |
+
fileName,
|
| 134 |
+
{ type: response.headers.get("content-type") || "application/octet-stream" }
|
| 135 |
+
);
|
| 136 |
+
};
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function initializePanelAccessibility() {
|
| 140 |
+
document.querySelector(".reader-panel-tabs")?.setAttribute("role", "tablist");
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function mountInitialLoadingFeedback() {
|
| 144 |
+
readerContext.loadingIndicator.className = "reader-loading-indicator";
|
| 145 |
+
readerContext.loadingIndicator.setAttribute("role", "status");
|
| 146 |
+
readerContext.loadingIndicator.innerHTML =
|
| 147 |
+
'<span class="reader-loading-spinner" aria-hidden="true"></span><span>正在加载正文...</span>';
|
| 148 |
+
readerContext.content.appendChild(readerContext.loadingIndicator);
|
| 149 |
+
readerContext.content.dataset.mode = readerContext.capability.mode || "unsupported";
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
function bindDownloadAction() {
|
| 153 |
+
readerContext.downloadButton.addEventListener("click", readerContext.startReaderDownload);
|
| 154 |
+
readerContext.loadingStatus.textContent = "";
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
function initializeDocumentTitle() {
|
| 158 |
+
readerContext.updateDocumentState({
|
| 159 |
+
title:
|
| 160 |
+
String(
|
| 161 |
+
readerContext.params.get("title") || (readerContext.localReaderData && readerContext.localReaderData.title) || readerContext.sourceName
|
| 162 |
+
).trim() || readerContext.sourceName,
|
| 163 |
+
zoom: Math.min(4, Math.max(0.25, Number(localStorage.getItem("reader-zoom") || 100) / 100))
|
| 164 |
+
});
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
function restoreReturnNavigation() {
|
| 168 |
+
try {
|
| 169 |
+
const state = history.state;
|
| 170 |
+
const saved = JSON.parse(sessionStorage.getItem("reader-navigation-current") || "null");
|
| 171 |
+
let stateUrl =
|
| 172 |
+
state && state.voiceReaderOverlay && state.readerUrl
|
| 173 |
+
? new URL(state.readerUrl, location.origin)
|
| 174 |
+
: null;
|
| 175 |
+
if (!stateUrl && saved && saved.shareUrl === location.href && saved.readerUrl)
|
| 176 |
+
stateUrl = new URL(saved.readerUrl, location.origin);
|
| 177 |
+
const cleanStateUrl = stateUrl && new URL(stateUrl.href);
|
| 178 |
+
if (cleanStateUrl) {
|
| 179 |
+
cleanStateUrl.searchParams.delete("return");
|
| 180 |
+
cleanStateUrl.searchParams.delete("nav");
|
| 181 |
+
}
|
| 182 |
+
if (
|
| 183 |
+
stateUrl &&
|
| 184 |
+
stateUrl.origin === location.origin &&
|
| 185 |
+
stateUrl.pathname === "/static/reader.html" &&
|
| 186 |
+
cleanStateUrl.href === location.href
|
| 187 |
+
) {
|
| 188 |
+
if (!readerContext.returnUrl) readerContext.returnUrl = stateUrl.searchParams.get("return") || "";
|
| 189 |
+
if (!readerContext.returnNavigationToken) readerContext.returnNavigationToken = stateUrl.searchParams.get("nav") || "";
|
| 190 |
+
readerContext.returnNeedsReload = true;
|
| 191 |
+
}
|
| 192 |
+
} catch (_) {}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function validateReturnHistory() {
|
| 196 |
+
try {
|
| 197 |
+
const target = new URL(readerContext.returnUrl, location.origin);
|
| 198 |
+
const storedReturnUrl = readerContext.returnHistoryKey ? sessionStorage.getItem(readerContext.returnHistoryKey) : "";
|
| 199 |
+
readerContext.canReturnWithHistory = !!readerContext.returnHistoryKey && storedReturnUrl === target.href;
|
| 200 |
+
} catch (_) {}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
function ownChapterSearchClient() {
|
| 204 |
+
readerContext.trackReaderResource(() => {
|
| 205 |
+
readerContext.chapterSearchClient?.dispose();
|
| 206 |
+
readerContext.chapterSearchClient = null;
|
| 207 |
+
});
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function ownDocxPageNodes() {
|
| 211 |
+
readerContext.trackReaderResource(() => { readerContext.docxPageNodes = []; });
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
function ownIndependentPdfResources() {
|
| 215 |
+
readerContext.trackReaderResource(() => {
|
| 216 |
+
for (const release of [...readerContext.pdfV3BlobReleases]) release();
|
| 217 |
+
readerContext.pdfV3Repository?.dispose(); readerContext.pdfTextLayerRepository?.dispose();
|
| 218 |
+
});
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
function ownPdfBookSearchClient() {
|
| 222 |
+
readerContext.trackReaderResource(() => {
|
| 223 |
+
readerContext.pdfBookSearchClient?.dispose();
|
| 224 |
+
readerContext.pdfBookSearchClient = null;
|
| 225 |
+
clearTimeout(readerContext.pdfBookSearchPrefetchTimer);
|
| 226 |
+
readerContext.pdfBookSearchPrefetchTimer = 0;
|
| 227 |
+
readerContext.pdfBookSearchPrefetchPromise = null;
|
| 228 |
+
});
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
function ownPdfPrefetchAndQueues() {
|
| 232 |
+
readerContext.trackReaderResource(readerContext.clearPdfManifestPrefetches);
|
| 233 |
+
readerContext.trackReaderResource(() => {
|
| 234 |
+
for (const waiter of readerContext.pdfRenderWaiters.splice(0)) waiter.reject(readerContext.readerAbortError());
|
| 235 |
+
for (const waiter of readerContext.pdfTextWaiters.splice(0)) waiter.reject(readerContext.readerAbortError());
|
| 236 |
+
});
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
function initializeGenerations() {
|
| 240 |
+
for (const generationName of ["navigation", "search", "bookmarks", "history", "pdf"])
|
| 241 |
+
readerContext.nextReaderGeneration(generationName);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
function labelToolbarControls() {
|
| 245 |
+
for (const [selector, label] of [
|
| 246 |
+
["#back", "返回"],
|
| 247 |
+
["#page-prev", "上一页"],
|
| 248 |
+
["#page-next", "下一页"],
|
| 249 |
+
["#page-number", "页码"],
|
| 250 |
+
["#zoom-out", "缩小"],
|
| 251 |
+
["#zoom-in", "放大"],
|
| 252 |
+
["#zoom", "缩放百分比"],
|
| 253 |
+
["#bookmark-ribbon", "添加书签"],
|
| 254 |
+
["#history", "阅读选项"]
|
| 255 |
+
])
|
| 256 |
+
document.querySelector(selector)?.setAttribute("aria-label", label);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
function applyMetadataAndWatchSanitizedContent() {
|
| 260 |
+
readerContext.applyReaderMetadata(readerContext.resolvedReaderData);
|
| 261 |
+
readerContext.contentSanitizerObserver = new MutationObserver(() => {
|
| 262 |
+
const seen = new Set();
|
| 263 |
+
for (const article of document.querySelectorAll(".foliate-continuous > article[data-section]")) {
|
| 264 |
+
article.querySelectorAll("style,link[rel=stylesheet]").forEach((node) => node.remove());
|
| 265 |
+
const index = article.dataset.section;
|
| 266 |
+
if (seen.has(index)) article.remove();
|
| 267 |
+
else seen.add(index);
|
| 268 |
+
}
|
| 269 |
+
});
|
| 270 |
+
readerContext.contentSanitizerObserver.observe(readerContext.content, { childList: true, subtree: true });
|
| 271 |
+
readerContext.bookmarkPopover.className = "bookmark-popover";
|
| 272 |
+
readerContext.bookmarkPopover.setAttribute("role", "dialog");
|
| 273 |
+
readerContext.bookmarkPopover.setAttribute("aria-modal", "true");
|
| 274 |
+
readerContext.bookmarkPopover.setAttribute("aria-labelledby", "bookmark-prompt");
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function initializeBookmarkEditor() {
|
| 278 |
+
readerContext.bookmarkLabelInput.id = "bookmark-label";
|
| 279 |
+
readerContext.bookmarkLabelInput.maxLength = 120;
|
| 280 |
+
readerContext.bookmarkExcerptInput.id = "bookmark-excerpt-input";
|
| 281 |
+
readerContext.bookmarkExcerptInput.maxLength = 500;
|
| 282 |
+
readerContext.bookmarkExcerptInput.rows = 3;
|
| 283 |
+
readerContext.bookmarkEditFields.className = "bookmark-edit-fields";
|
| 284 |
+
readerContext.bookmarkEditFields.innerHTML = "<label>标题</label><label>摘要</label>";
|
| 285 |
+
readerContext.bookmarkEditFields.children[0].appendChild(readerContext.bookmarkLabelInput);
|
| 286 |
+
readerContext.bookmarkEditFields.children[1].appendChild(readerContext.bookmarkExcerptInput);
|
| 287 |
+
readerContext.bookmarkPopover.insertBefore(readerContext.bookmarkEditFields, readerContext.bookmarkPopover.querySelector("div"));
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
function initializeBookmarkSearchControls() {
|
| 291 |
+
if (!document.querySelector("#bookmarks-panel .panel-search-toggle")) {
|
| 292 |
+
const button = document.createElement("button");
|
| 293 |
+
button.className = "panel-search-toggle";
|
| 294 |
+
button.type = "button";
|
| 295 |
+
document.querySelector("#bookmarks-panel .panel-view-header").appendChild(button);
|
| 296 |
+
}
|
| 297 |
+
readerContext.bookmarksAllButton.id = "bookmarks-all";
|
| 298 |
+
readerContext.bookmarksAllButton.className = "text-action";
|
| 299 |
+
readerContext.bookmarksAllButton.type = "button";
|
| 300 |
+
readerContext.bookmarksAllButton.textContent = "全部书签";
|
| 301 |
+
readerContext.bookmarksAllButton.setAttribute("aria-pressed", "false");
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
function mountBookmarkHeaderActions() {
|
| 305 |
+
readerContext.bookmarksHeaderActions.append(readerContext.bookmarksAllButton, readerContext.bookmarksSearchButton);
|
| 306 |
+
readerContext.bookmarksHeader.appendChild(readerContext.bookmarksHeaderActions);
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
function mountMediaPanel() {
|
| 310 |
+
readerContext.mediaTab.id = "media-tab";
|
| 311 |
+
readerContext.mediaTab.type = "button";
|
| 312 |
+
readerContext.mediaTab.role = "tab";
|
| 313 |
+
readerContext.mediaTab.dataset.panel = "media";
|
| 314 |
+
readerContext.mediaTab.textContent = "播放";
|
| 315 |
+
readerContext.mediaTab.hidden = !readerContext.capability.features.media;
|
| 316 |
+
readerContext.mediaPanel.id = "media-panel";
|
| 317 |
+
readerContext.mediaPanel.className = "reader-panel-view";
|
| 318 |
+
readerContext.mediaPanel.dataset.panelView = "media";
|
| 319 |
+
readerContext.mediaPanel.hidden = true;
|
| 320 |
+
readerContext.mediaPanel.innerHTML =
|
| 321 |
+
'<div class="media-panel-content"><strong>播放状态</strong><span class="media-panel-time">尚未播放</span><button class="text-action media-panel-bookmark" type="button">在当前时间添加书签</button></div>';
|
| 322 |
+
document.querySelector(".reader-panel-tabs").prepend(readerContext.mediaTab);
|
| 323 |
+
document
|
| 324 |
+
.querySelector("#history-panel")
|
| 325 |
+
.insertBefore(readerContext.mediaPanel, document.querySelector("#toc-panel"));
|
| 326 |
+
readerContext.mediaPanel
|
| 327 |
+
.querySelector(".media-panel-bookmark")
|
| 328 |
+
.addEventListener("click", (event) => readerContext.openBookmarkPopover(event.currentTarget));
|
| 329 |
+
document.querySelector(".page-controls").hidden = !readerContext.capability.features.pagination;
|
| 330 |
+
document.querySelector(".zoom-controls").hidden = !readerContext.capability.features.zoom;
|
| 331 |
+
|
| 332 |
+
readerContext.status.hidden = true;
|
| 333 |
+
readerContext.mediaTab.setAttribute("aria-controls", "media-panel");
|
| 334 |
+
readerContext.mediaTab.setAttribute("aria-selected", "false");
|
| 335 |
+
readerContext.mediaPanel.setAttribute("role", "tabpanel");
|
| 336 |
+
readerContext.mediaPanel.setAttribute("aria-labelledby", "media-tab");
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
function restoreAndBindPageInversion() {
|
| 340 |
+
try { readerContext.pageInverted = localStorage.getItem("reader-page-inversion") === "true"; } catch (_) {}
|
| 341 |
+
|
| 342 |
+
readerContext.pageInvertToggle.addEventListener("click", () => {
|
| 343 |
+
readerContext.pageInverted = !readerContext.pageInverted;
|
| 344 |
+
readerContext.applyPageInversion(true);
|
| 345 |
+
});
|
| 346 |
+
readerContext.applyPageInversion();
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
function restoreReaderThemeAndBindReturn() {
|
| 350 |
+
readerContext.readerThemeToggle.className = "theme-toggle";
|
| 351 |
+
|
| 352 |
+
readerContext.applyReaderTheme(readerContext.readerTheme, false, false);
|
| 353 |
+
|
| 354 |
+
readerContext.readerPath.addEventListener("click", readerContext.navigateReaderFolder);
|
| 355 |
+
document.querySelector("#back").addEventListener("click", async () => {
|
| 356 |
+
clearTimeout(readerContext.saveTimer);
|
| 357 |
+
await Promise.race([readerContext.saveProgress(), new Promise((resolve) => setTimeout(resolve, 300))]);
|
| 358 |
+
if (window.parent !== window) {
|
| 359 |
+
window.parent.postMessage({ type: "voice-reader-close" }, location.origin);
|
| 360 |
+
return;
|
| 361 |
+
}
|
| 362 |
+
readerContext.clearReturnNavigation();
|
| 363 |
+
try {
|
| 364 |
+
const target = new URL(readerContext.returnUrl, location.origin);
|
| 365 |
+
if (target.origin === location.origin) {
|
| 366 |
+
if (readerContext.returnNeedsReload) location.replace(target.href);
|
| 367 |
+
else if (readerContext.canReturnWithHistory && history.length > 2) {
|
| 368 |
+
history.go(-2);
|
| 369 |
+
} else location.assign(target.href);
|
| 370 |
+
return;
|
| 371 |
+
}
|
| 372 |
+
} catch (_) {}
|
| 373 |
+
location.assign("/");
|
| 374 |
+
});
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
function bindZoomAndPageControls() {
|
| 378 |
+
readerContext.setZoom(readerContext.documentState.zoom * 100, false);
|
| 379 |
+
for (const [id, delta] of [
|
| 380 |
+
["#zoom-out", -10],
|
| 381 |
+
["#zoom-in", 10]
|
| 382 |
+
]) {
|
| 383 |
+
document.querySelector(id).addEventListener("click", () => {
|
| 384 |
+
readerContext.setZoom(Number(readerContext.zoomInput.value) + delta);
|
| 385 |
+
});
|
| 386 |
+
}
|
| 387 |
+
readerContext.zoomInput.addEventListener("change", () => readerContext.setZoom(readerContext.zoomInput.value));
|
| 388 |
+
readerContext.zoomInput.addEventListener("keydown", (event) => {
|
| 389 |
+
if (event.key === "Enter") {
|
| 390 |
+
readerContext.setZoom(readerContext.zoomInput.value);
|
| 391 |
+
readerContext.zoomInput.blur();
|
| 392 |
+
}
|
| 393 |
+
});
|
| 394 |
+
readerContext.pageInput.addEventListener("change", () => readerContext.goToPage(readerContext.pageInput.value));
|
| 395 |
+
readerContext.pageInput.addEventListener("keydown", (event) => {
|
| 396 |
+
if (event.key === "Enter") {
|
| 397 |
+
readerContext.goToPage(readerContext.pageInput.value);
|
| 398 |
+
readerContext.pageInput.blur();
|
| 399 |
+
}
|
| 400 |
+
});
|
| 401 |
+
|
| 402 |
+
document.addEventListener("keydown", readerContext.handleReaderKeydown);
|
| 403 |
+
document
|
| 404 |
+
.querySelector("#page-prev")
|
| 405 |
+
.addEventListener("click", () =>
|
| 406 |
+
readerContext.documentState.pageCount ? readerContext.goToPage(readerContext.documentState.page - 1) : readerContext.turnViewport(-1)
|
| 407 |
+
);
|
| 408 |
+
document
|
| 409 |
+
.querySelector("#page-next")
|
| 410 |
+
.addEventListener("click", () =>
|
| 411 |
+
readerContext.documentState.pageCount ? readerContext.goToPage(readerContext.documentState.page + 1) : readerContext.turnViewport(1)
|
| 412 |
+
);
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
function bindReaderUiHistory() {
|
| 416 |
+
if (window.parent === window) {
|
| 417 |
+
history.replaceState({ ...history.state, voiceReaderBase: true }, "", location.href);
|
| 418 |
+
history.pushState({ voiceReaderGuard: true }, "", location.href);
|
| 419 |
+
window.addEventListener("popstate", (event) => {
|
| 420 |
+
if (!event.state?.voiceReaderBase || readerContext.readerLifecycle.disposed) return;
|
| 421 |
+
if (document.querySelector("#history-panel").classList.contains("is-open") && !readerContext.fullSearchView.hidden)
|
| 422 |
+
readerContext.closeFullSearchView();
|
| 423 |
+
else if (document.querySelector("#history-panel").classList.contains("is-open"))
|
| 424 |
+
readerContext.setReaderPanelOpen(false, true);
|
| 425 |
+
else {
|
| 426 |
+
history.back();
|
| 427 |
+
return;
|
| 428 |
+
}
|
| 429 |
+
history.pushState({ voiceReaderGuard: true }, "", location.href);
|
| 430 |
+
});
|
| 431 |
+
}
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
function bindPanelBookmarkAndLifecycleControls() {
|
| 435 |
+
readerContext.trackReaderResource(() => { readerContext.headingLocationCache?.dispose(); readerContext.headingLocationCache = null; });
|
| 436 |
+
|
| 437 |
+
document.addEventListener("click", async (event) => {
|
| 438 |
+
const path = event.composedPath(),
|
| 439 |
+
link = path.find((node) => node instanceof Element && node.matches?.("a[href]")),
|
| 440 |
+
article = path.find(
|
| 441 |
+
(node) =>
|
| 442 |
+
node instanceof Element && node.matches?.(".foliate-continuous article[data-section]")
|
| 443 |
+
);
|
| 444 |
+
if (!link || !article || !readerContext.epubRendition?.book) return;
|
| 445 |
+
const raw = link.getAttribute("href") || "";
|
| 446 |
+
if (raw.startsWith("/api/reader-resource?")) return;
|
| 447 |
+
if (!raw || readerContext.epubRendition.book.isExternal?.(raw) || /^(?:https?:|mailto:|tel:)/i.test(raw))
|
| 448 |
+
return;
|
| 449 |
+
event.preventDefault();
|
| 450 |
+
const generation = readerContext.beginReaderNavigation(),
|
| 451 |
+
index = Number(article.dataset.section),
|
| 452 |
+
section = readerContext.epubRendition.book.sections.filter((item) => item.linear !== "no")[index];
|
| 453 |
+
let href = raw;
|
| 454 |
+
try {
|
| 455 |
+
href = section?.resolveHref?.(raw) || raw;
|
| 456 |
+
} catch (_) {}
|
| 457 |
+
try {
|
| 458 |
+
await readerContext.activateFoliateTocEntry({ href }, generation);
|
| 459 |
+
} catch (error) {
|
| 460 |
+
readerContext.reportNavigationError(error, generation);
|
| 461 |
+
}
|
| 462 |
+
});
|
| 463 |
+
|
| 464 |
+
// Bookmark dialog owns focus and temporarily makes only its siblings inert.
|
| 465 |
+
|
| 466 |
+
readerContext.bookmarkRibbon.addEventListener("click", () => readerContext.openBookmarkPopover(readerContext.bookmarkRibbon));
|
| 467 |
+
document.querySelector("#bookmark-cancel").addEventListener("click", readerContext.closeBookmarkPopover);
|
| 468 |
+
document.querySelector("#bookmark-add").addEventListener("click", async (event) => {
|
| 469 |
+
const button = event.currentTarget,
|
| 470 |
+
editing = readerContext.panelState.editingBookmark;
|
| 471 |
+
const snapshot = readerContext.pendingBookmarkSnapshot || editing || readerContext.captureBookmarkSnapshot();
|
| 472 |
+
const label = readerContext.bookmarkLabelInput.value.trim() || snapshot.label,
|
| 473 |
+
excerpt = readerContext.bookmarkExcerptInput.value.trim();
|
| 474 |
+
button.disabled = true;
|
| 475 |
+
try {
|
| 476 |
+
await VoiceOfMLReaderStore.putBookmark(
|
| 477 |
+
editing
|
| 478 |
+
? { ...editing, label, excerpt }
|
| 479 |
+
: {
|
| 480 |
+
...snapshot,
|
| 481 |
+
id: `${readerContext.sourceUrl}\0${snapshot.locator}`,
|
| 482 |
+
url: readerContext.sourceUrl,
|
| 483 |
+
title: readerContext.documentState.title,
|
| 484 |
+
extension: readerContext.extension,
|
| 485 |
+
readerUrl: readerContext.currentReaderUrl().href,
|
| 486 |
+
label,
|
| 487 |
+
excerpt,
|
| 488 |
+
createdAt: Date.now()
|
| 489 |
+
}
|
| 490 |
+
);
|
| 491 |
+
const invoker = readerContext.bookmarkInvoker;
|
| 492 |
+
readerContext.closeBookmarkPopover();
|
| 493 |
+
if (!document.querySelector("#bookmarks-panel").hidden) {
|
| 494 |
+
await readerContext.renderBookmarks();
|
| 495 |
+
if (editing && invoker && !invoker.isConnected && document.activeElement === document.body)
|
| 496 |
+
(
|
| 497 |
+
document.querySelector(
|
| 498 |
+
`#bookmarks-list [data-bookmark-id="${CSS.escape(editing.id)}"] .panel-item-edit`
|
| 499 |
+
) || readerContext.bookmarksAllButton
|
| 500 |
+
).focus();
|
| 501 |
+
}
|
| 502 |
+
} catch (_) {
|
| 503 |
+
document.querySelector("#bookmark-prompt").textContent = "书签保存失败,请重试。";
|
| 504 |
+
} finally {
|
| 505 |
+
button.disabled = false;
|
| 506 |
+
}
|
| 507 |
+
});
|
| 508 |
+
readerContext.bookmarkPopover.addEventListener("keydown", (event) => {
|
| 509 |
+
if (event.key === "Escape") {
|
| 510 |
+
event.preventDefault();
|
| 511 |
+
readerContext.closeBookmarkPopover();
|
| 512 |
+
return;
|
| 513 |
+
}
|
| 514 |
+
if (event.key !== "Tab") return;
|
| 515 |
+
const controls = [
|
| 516 |
+
...readerContext.bookmarkPopover.querySelectorAll("input,textarea,select,button,a[href],[tabindex]")
|
| 517 |
+
].filter((node) => !node.disabled && node.tabIndex >= 0 && node.getClientRects().length);
|
| 518 |
+
const first = controls[0],
|
| 519 |
+
last = controls[controls.length - 1];
|
| 520 |
+
if (event.shiftKey && document.activeElement === first) {
|
| 521 |
+
event.preventDefault();
|
| 522 |
+
last.focus();
|
| 523 |
+
} else if (!event.shiftKey && document.activeElement === last) {
|
| 524 |
+
event.preventDefault();
|
| 525 |
+
first.focus();
|
| 526 |
+
}
|
| 527 |
+
});
|
| 528 |
+
for (const button of document.querySelectorAll(".reader-panel-tabs button"))
|
| 529 |
+
button.addEventListener("click", () => readerContext.selectPanel(button.dataset.panel));
|
| 530 |
+
document.querySelector(".reader-panel-tabs").addEventListener("keydown", (event) => {
|
| 531 |
+
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
| 532 |
+
const tabs = [...document.querySelectorAll(".reader-panel-tabs button:not([hidden])")],
|
| 533 |
+
current = tabs.indexOf(document.activeElement);
|
| 534 |
+
if (current < 0) return;
|
| 535 |
+
event.preventDefault();
|
| 536 |
+
const next =
|
| 537 |
+
event.key === "Home"
|
| 538 |
+
? 0
|
| 539 |
+
: event.key === "End"
|
| 540 |
+
? tabs.length - 1
|
| 541 |
+
: (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
|
| 542 |
+
readerContext.selectPanel(tabs[next].dataset.panel);
|
| 543 |
+
tabs[next].focus();
|
| 544 |
+
});
|
| 545 |
+
readerContext.bookmarksAllButton.addEventListener("click", () => {
|
| 546 |
+
readerContext.updatePanelState({ showingAllBookmarks: !readerContext.panelState.showingAllBookmarks });
|
| 547 |
+
readerContext.bookmarksAllButton.textContent = readerContext.panelState.showingAllBookmarks ? "本书书签" : "全部书签";
|
| 548 |
+
readerContext.bookmarksAllButton.setAttribute("aria-pressed", String(readerContext.panelState.showingAllBookmarks));
|
| 549 |
+
readerContext.renderBookmarks();
|
| 550 |
+
});
|
| 551 |
+
for (const button of document.querySelectorAll(".panel-search-toggle")) {
|
| 552 |
+
button.classList.remove("icon-button");
|
| 553 |
+
button.classList.add("text-action");
|
| 554 |
+
button.textContent = "搜索";
|
| 555 |
+
button.hidden = false;
|
| 556 |
+
button.setAttribute("aria-expanded", "false");
|
| 557 |
+
button.addEventListener("click", () =>
|
| 558 |
+
readerContext.setPanelSearchOpen(button, button.getAttribute("aria-expanded") !== "true")
|
| 559 |
+
);
|
| 560 |
+
}
|
| 561 |
+
for (const input of document.querySelectorAll(".panel-search")) {
|
| 562 |
+
const view = input.closest(".reader-panel-view");
|
| 563 |
+
input.type = "search";
|
| 564 |
+
input.setAttribute(
|
| 565 |
+
"aria-label",
|
| 566 |
+
`搜索${view.querySelector(".panel-view-header strong")?.textContent || "列表"}`
|
| 567 |
+
);
|
| 568 |
+
input.addEventListener("input", (event) => {
|
| 569 |
+
if (!event.isComposing) readerContext.filterPanel(view);
|
| 570 |
+
});
|
| 571 |
+
input.addEventListener("compositionend", () => readerContext.filterPanel(view));
|
| 572 |
+
}
|
| 573 |
+
document.querySelector("#history-clear").addEventListener("click", async () => {
|
| 574 |
+
if (!confirm("清空全部阅读历史?")) return;
|
| 575 |
+
readerContext.historySuppressed = true;
|
| 576 |
+
await readerContext.progressSaveChain;
|
| 577 |
+
await VoiceOfMLReaderStore.clearHistory();
|
| 578 |
+
readerContext.renderHistory();
|
| 579 |
+
});
|
| 580 |
+
readerContext.readerThemeToggle.addEventListener("click", () => {
|
| 581 |
+
const theme = readerContext.readerTheme === "dark" ? "light" : "dark";
|
| 582 |
+
readerContext.applyReaderTheme(theme);
|
| 583 |
+
if (window.parent !== window)
|
| 584 |
+
window.parent.postMessage({ type: "voice-reader-theme", theme }, location.origin);
|
| 585 |
+
});
|
| 586 |
+
window.addEventListener("storage", (event) => {
|
| 587 |
+
if (event.key === "theme" && event.newValue) readerContext.applyReaderTheme(event.newValue, false);
|
| 588 |
+
});
|
| 589 |
+
window.addEventListener("voice-reader-dispose", () => {
|
| 590 |
+
readerContext.saveProgress();
|
| 591 |
+
readerContext.disposeReader();
|
| 592 |
+
});
|
| 593 |
+
window.addEventListener("message", (event) => {
|
| 594 |
+
if (
|
| 595 |
+
event.origin === location.origin &&
|
| 596 |
+
event.source === window.parent &&
|
| 597 |
+
event.data?.type === "voice-reader-abort"
|
| 598 |
+
) {
|
| 599 |
+
readerContext.saveProgress();
|
| 600 |
+
readerContext.disposeReader();
|
| 601 |
+
return;
|
| 602 |
+
}
|
| 603 |
+
if (
|
| 604 |
+
event.origin === location.origin &&
|
| 605 |
+
event.source === window.parent &&
|
| 606 |
+
event.data &&
|
| 607 |
+
event.data.type === "voice-reader-theme-state"
|
| 608 |
+
)
|
| 609 |
+
readerContext.applyReaderTheme(event.data.theme, false);
|
| 610 |
+
if (event.origin === location.origin && event.source === window.parent &&
|
| 611 |
+
event.data?.type === "voice-reader-system-back") {
|
| 612 |
+
if (readerContext.readerUiHistoryState === "full-search") readerContext.closeFullSearchView();
|
| 613 |
+
else if (readerContext.readerUiHistoryState === "panel") readerContext.setReaderPanelOpen(false, true);
|
| 614 |
+
}
|
| 615 |
+
});
|
| 616 |
+
document
|
| 617 |
+
.querySelector("#history")
|
| 618 |
+
.addEventListener("click", () =>
|
| 619 |
+
readerContext.setReaderPanelOpen(document.querySelector("#history").getAttribute("aria-expanded") !== "true")
|
| 620 |
+
);
|
| 621 |
+
document
|
| 622 |
+
.querySelector("#history-close")
|
| 623 |
+
.addEventListener("click", () => readerContext.setReaderPanelOpen(false, true));
|
| 624 |
+
readerContext.viewport.addEventListener("scroll", readerContext.handleReaderPositionChange, { passive: true });
|
| 625 |
+
|
| 626 |
+
for (const type of ["wheel", "touchstart", "pointerdown"])
|
| 627 |
+
readerContext.viewport.addEventListener(type, readerContext.notePdfScrollIntent, { passive: true });
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
function bindPdfPointerSelection() {
|
| 631 |
+
readerContext.installReaderTapSelectionClear(readerContext.viewport);
|
| 632 |
+
readerContext.viewport.addEventListener("pointerdown", (event) => {
|
| 633 |
+
if (event.pointerType !== "mouse") {
|
| 634 |
+
readerContext.pdfSelectionPointer = null;
|
| 635 |
+
return;
|
| 636 |
+
}
|
| 637 |
+
const run = event.target.closest?.(".reader-pdf-text-run");
|
| 638 |
+
const selection = document.getSelection();
|
| 639 |
+
const selectedRun = !selection?.isCollapsed && event.target.closest?.(".reader-page")
|
| 640 |
+
? selection.anchorNode?.parentElement?.closest(".reader-pdf-text-run") : null;
|
| 641 |
+
const origin = run || selectedRun;
|
| 642 |
+
const caret = !event.shiftKey && event.button === 0 ? readerContext.independentTextCaret(document, event.clientX, event.clientY) : null;
|
| 643 |
+
readerContext.pdfSelectionPointer = origin
|
| 644 |
+
? { id: event.pointerId, x: event.clientX, y: event.clientY, frame: 0,
|
| 645 |
+
page: origin.closest(".reader-page"), anchorNode: caret?.node || (run ? null : selection.anchorNode),
|
| 646 |
+
anchorOffset: caret?.offset ?? (run ? 0 : selection.anchorOffset) } : null;
|
| 647 |
+
}, { passive: true });
|
| 648 |
+
document.addEventListener("pointermove", (event) => {
|
| 649 |
+
const start = readerContext.pdfSelectionPointer;
|
| 650 |
+
if (event.pointerType !== "mouse" || !start || start.id !== event.pointerId ||
|
| 651 |
+
Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) return;
|
| 652 |
+
const selection = document.getSelection();
|
| 653 |
+
if (!start.anchorNode && selection?.rangeCount && start.page.contains(selection.anchorNode) &&
|
| 654 |
+
selection.anchorNode.parentElement?.closest(".reader-pdf-text-run")) {
|
| 655 |
+
start.anchorNode = selection.anchorNode;
|
| 656 |
+
start.anchorOffset = selection.anchorOffset;
|
| 657 |
+
}
|
| 658 |
+
if (start.anchorNode) {
|
| 659 |
+
start.endX = event.clientX;
|
| 660 |
+
start.endY = event.clientY;
|
| 661 |
+
if (!start.frame) start.frame = requestAnimationFrame(() => {
|
| 662 |
+
start.frame = 0;
|
| 663 |
+
if (readerContext.pdfSelectionPointer === start)
|
| 664 |
+
readerContext.correctPdfCrossPageSelection(start.endX, start.endY, start);
|
| 665 |
+
});
|
| 666 |
+
}
|
| 667 |
+
}, { passive: true });
|
| 668 |
+
document.addEventListener("pointerup", (event) => {
|
| 669 |
+
const start = readerContext.pdfSelectionPointer;
|
| 670 |
+
readerContext.pdfSelectionPointer = null;
|
| 671 |
+
if (event.pointerType !== "mouse" || !["pdf", "pdf-pages"].includes(readerContext.capability.mode) ||
|
| 672 |
+
!start || start.id !== event.pointerId ||
|
| 673 |
+
Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) return;
|
| 674 |
+
const x = event.clientX, y = event.clientY;
|
| 675 |
+
requestAnimationFrame(() => readerContext.correctPdfCrossPageSelection(x, y, start));
|
| 676 |
+
}, { passive: true });
|
| 677 |
+
document.addEventListener("pointercancel", () => { readerContext.pdfSelectionPointer = null; }, { passive: true });
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
function bindSelectionAndPageLifecycle() {
|
| 681 |
+
document.addEventListener("selectionchange", () => {
|
| 682 |
+
if (readerContext.pdfSelectionBoundaryUpdate ||
|
| 683 |
+
!["pdf", "pdf-pages"].includes(readerContext.capability.mode)) return;
|
| 684 |
+
const selection = document.getSelection();
|
| 685 |
+
if (!selection || selection.isCollapsed || !selection.rangeCount) return;
|
| 686 |
+
const runs = [...readerContext.content.querySelectorAll(".reader-pdf-text-run")]
|
| 687 |
+
.filter((run) => run.firstChild?.nodeType === Node.TEXT_NODE);
|
| 688 |
+
if (!runs.length) return;
|
| 689 |
+
const anchorRun = selection.anchorNode?.parentElement?.closest(".reader-pdf-text-run");
|
| 690 |
+
const focusRun = selection.focusNode?.parentElement?.closest(".reader-pdf-text-run");
|
| 691 |
+
if (!anchorRun && !focusRun) return;
|
| 692 |
+
if (focusRun) return;
|
| 693 |
+
const beforeAnchor = selection.focusNode && selection.anchorNode &&
|
| 694 |
+
(selection.focusNode === selection.anchorNode
|
| 695 |
+
? selection.focusOffset < selection.anchorOffset
|
| 696 |
+
: Boolean(selection.focusNode.compareDocumentPosition(selection.anchorNode) & Node.DOCUMENT_POSITION_FOLLOWING));
|
| 697 |
+
const target = beforeAnchor ? runs[0] : runs[runs.length - 1];
|
| 698 |
+
const node = target.firstChild;
|
| 699 |
+
readerContext.pdfSelectionBoundaryUpdate = true;
|
| 700 |
+
try {
|
| 701 |
+
selection.setBaseAndExtent(selection.anchorNode, selection.anchorOffset, node,
|
| 702 |
+
beforeAnchor ? 0 : node.length);
|
| 703 |
+
} finally {
|
| 704 |
+
readerContext.pdfSelectionBoundaryUpdate = false;
|
| 705 |
+
}
|
| 706 |
+
}, { passive: true });
|
| 707 |
+
window.addEventListener("pagehide", (event) => {
|
| 708 |
+
readerContext.saveProgress(event);
|
| 709 |
+
if (!event.persisted) readerContext.disposeReader();
|
| 710 |
+
});
|
| 711 |
+
window.addEventListener("pageshow", (event) => {
|
| 712 |
+
if (event.persisted && !readerContext.readerLifecycle.disposed) {
|
| 713 |
+
readerContext.scheduleFoliateScrollSync();
|
| 714 |
+
readerContext.updateProgressTools();
|
| 715 |
+
}
|
| 716 |
+
});
|
| 717 |
+
document.addEventListener("visibilitychange", () => {
|
| 718 |
+
if (document.visibilityState === "hidden") readerContext.saveProgress();
|
| 719 |
+
if (document.hidden) readerContext.stopPdfManifestPrefetch();
|
| 720 |
+
else readerContext.queuePdfManifestPrefetch();
|
| 721 |
+
});
|
| 722 |
+
window.addEventListener("offline", readerContext.stopPdfManifestPrefetch);
|
| 723 |
+
window.addEventListener("online", () => readerContext.queuePdfManifestPrefetch());
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
function mountProgressControls() {
|
| 727 |
+
readerContext.progressTools.className = "reader-progress-tools";
|
| 728 |
+
readerContext.progressTools.innerHTML =
|
| 729 |
+
'<div class="reader-progress-row"><button class="reader-chapter-prev" type="button" aria-label="上一章" title="上一章"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m14 5-7 7 7 7"/></svg></button><input class="reader-progress-range" type="range" min="0" max="100" step="0.1" value="0" aria-label="阅读进度"><span class="reader-progress-percent">0.0%</span><button class="reader-progress-undo" type="button" aria-label="撤销进度调整" title="撤销进度调整"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7.2 8.3A6.5 6.5 0 1 1 6 16"/><path d="m7.2 8.3 3-.2"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/></svg></button><button class="reader-chapter-next" type="button" aria-label="下一章" title="下一章"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m10 5 7 7-7 7"/></svg></button></div>';
|
| 730 |
+
document.querySelector("#history-panel").appendChild(readerContext.progressTools);
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
function mountProgressSummary() {
|
| 734 |
+
readerContext.readingProgressSummary.className = "reader-progress-summary";
|
| 735 |
+
readerContext.readingProgressSummary.innerHTML =
|
| 736 |
+
'<strong>阅读状态</strong><span class="reader-progress-summary-value">0.0%</span><button class="reader-progress-bookmark text-action" type="button">在当前位置添加书签</button>';
|
| 737 |
+
readerContext.progressTools.prepend(readerContext.readingProgressSummary);
|
| 738 |
+
readerContext.readingProgressSummary
|
| 739 |
+
.querySelector(".reader-progress-bookmark")
|
| 740 |
+
.addEventListener("click", (event) => readerContext.openBookmarkPopover(event.currentTarget));
|
| 741 |
+
|
| 742 |
+
readerContext.updateReadingProgressSummary();
|
| 743 |
+
|
| 744 |
+
readerContext.syncTocTab();
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
function bindProgressActions() {
|
| 748 |
+
readerContext.progressRange.addEventListener("pointerdown", () => {
|
| 749 |
+
readerContext.beginReaderNavigation();
|
| 750 |
+
readerContext.progressSeeking = true;
|
| 751 |
+
readerContext.previousProgressState = readerContext.captureProgressState();
|
| 752 |
+
});
|
| 753 |
+
readerContext.progressRange.addEventListener("input", () => {
|
| 754 |
+
readerContext.progressPercent.textContent = `${Number(readerContext.progressRange.value).toFixed(1)}%`;
|
| 755 |
+
readerContext.seekProgress(readerContext.progressRange.value, true);
|
| 756 |
+
});
|
| 757 |
+
for (const type of ["pointerup", "pointercancel"])
|
| 758 |
+
readerContext.progressRange.addEventListener(type, () => {
|
| 759 |
+
readerContext.progressSeeking = false;
|
| 760 |
+
const generation = readerContext.readerRuntime.currentGeneration("navigation");
|
| 761 |
+
readerContext.flushEpubSeek().then(() => {
|
| 762 |
+
if (readerContext.isReaderGenerationCurrent("navigation", generation)) readerContext.updateProgressTools();
|
| 763 |
+
});
|
| 764 |
+
});
|
| 765 |
+
readerContext.progressUndo.addEventListener("click", () => {
|
| 766 |
+
const state = readerContext.previousProgressState;
|
| 767 |
+
readerContext.previousProgressState = null;
|
| 768 |
+
readerContext.progressSeeking = false;
|
| 769 |
+
readerContext.restoreProgressState(state);
|
| 770 |
+
readerContext.updateProgressTools();
|
| 771 |
+
});
|
| 772 |
+
readerContext.chapterPrev.addEventListener("click", () =>
|
| 773 |
+
readerContext.activateChapter(readerContext.adjacentTocIndex(-1))
|
| 774 |
+
);
|
| 775 |
+
readerContext.chapterNext.addEventListener("click", () =>
|
| 776 |
+
readerContext.activateChapter(readerContext.adjacentTocIndex(1))
|
| 777 |
+
);
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
function ownDomSearchController() {
|
| 781 |
+
readerContext.trackReaderResource(readerContext.domSearch.dispose);
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
function ownSearchViewController() {
|
| 785 |
+
readerContext.trackReaderResource(readerContext.fullSearchController.dispose);
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
function ownPdfSearchStorageAndStart() {
|
| 789 |
+
readerContext.trackReaderResource(() => {
|
| 790 |
+
readerContext.pdfPersistentTextStore?.dispose();
|
| 791 |
+
readerContext.pdfPersistentTextStore = readerContext.pdfPersistentTextOpening = readerContext.pdfPersistentTextVersion = null;
|
| 792 |
+
readerContext.pdfSearchTextCache.clear();
|
| 793 |
+
readerContext.pdfSearchTextCacheBytes = 0;
|
| 794 |
+
});
|
| 795 |
+
|
| 796 |
+
readerContext.registerReaderFormatAdapters();
|
| 797 |
+
readerContext.syncCapabilityControls();
|
| 798 |
+
readerContext.updateProgressTools();
|
| 799 |
+
readerContext.start().catch((error) => {
|
| 800 |
+
if (readerContext.readerAbortController.signal.aborted || error?.name === "AbortError") return;
|
| 801 |
+
console.error(error);
|
| 802 |
+
readerContext.fail("阅读文件解析失败,请刷新后重试,或下载原文件。", readerContext.classifyReaderError(error));
|
| 803 |
+
});
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
return {
|
| 807 |
+
trackStartupOwners,
|
| 808 |
+
trackWorkCancellation,
|
| 809 |
+
trackEarlySourceResolution,
|
| 810 |
+
publishDiagnosticsAndInstallCompatibility,
|
| 811 |
+
readSelectedSourceSession,
|
| 812 |
+
normalizeConvertedPdfMode,
|
| 813 |
+
applySourceSessionAndCachedMetadata,
|
| 814 |
+
publishSourceAndSubscribeToStore,
|
| 815 |
+
initializePanelAccessibility,
|
| 816 |
+
mountInitialLoadingFeedback,
|
| 817 |
+
bindDownloadAction,
|
| 818 |
+
initializeDocumentTitle,
|
| 819 |
+
restoreReturnNavigation,
|
| 820 |
+
validateReturnHistory,
|
| 821 |
+
ownChapterSearchClient,
|
| 822 |
+
ownDocxPageNodes,
|
| 823 |
+
ownIndependentPdfResources,
|
| 824 |
+
ownPdfBookSearchClient,
|
| 825 |
+
ownPdfPrefetchAndQueues,
|
| 826 |
+
initializeGenerations,
|
| 827 |
+
labelToolbarControls,
|
| 828 |
+
applyMetadataAndWatchSanitizedContent,
|
| 829 |
+
initializeBookmarkEditor,
|
| 830 |
+
initializeBookmarkSearchControls,
|
| 831 |
+
mountBookmarkHeaderActions,
|
| 832 |
+
mountMediaPanel,
|
| 833 |
+
restoreAndBindPageInversion,
|
| 834 |
+
restoreReaderThemeAndBindReturn,
|
| 835 |
+
bindZoomAndPageControls,
|
| 836 |
+
bindReaderUiHistory,
|
| 837 |
+
bindPanelBookmarkAndLifecycleControls,
|
| 838 |
+
bindPdfPointerSelection,
|
| 839 |
+
bindSelectionAndPageLifecycle,
|
| 840 |
+
mountProgressControls,
|
| 841 |
+
mountProgressSummary,
|
| 842 |
+
bindProgressActions,
|
| 843 |
+
ownDomSearchController,
|
| 844 |
+
ownSearchViewController,
|
| 845 |
+
ownPdfSearchStorageAndStart
|
| 846 |
+
};
|
| 847 |
+
}
|
static/{reader-contract.js → reader/core/reader-contract.js}
RENAMED
|
File without changes
|
static/reader/core/reader-diagnostics.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function createReaderDiagnostics(readerContext) {
|
| 2 |
+
function pdfStage(stage, page = 0) {
|
| 3 |
+
const started = performance.now();
|
| 4 |
+
return (outcome = "ready") => {
|
| 5 |
+
if (readerContext.readerAbortController.signal.aborted) return;
|
| 6 |
+
readerContext.pdfPageTimings.push({at:started, stage, page, outcome, ms:performance.now()-started});
|
| 7 |
+
if (readerContext.pdfPageTimings.length > 64) readerContext.pdfPageTimings.shift();
|
| 8 |
+
};
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function measurePdf(promise, stage, page = 0) {
|
| 12 |
+
const finish = readerContext.pdfStage(stage,page);
|
| 13 |
+
return Promise.resolve(promise).then(value => { finish(); return value; }, error => {
|
| 14 |
+
finish(error?.name === "AbortError" ? "cancelled" : "failed"); throw error;
|
| 15 |
+
});
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
return {
|
| 19 |
+
pdfStage,
|
| 20 |
+
measurePdf
|
| 21 |
+
};
|
| 22 |
+
}
|
static/reader/core/reader-document-loader.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function createReaderDocumentLoader(readerContext) {
|
| 2 |
+
function loadScript(url) {
|
| 3 |
+
readerContext.assertReaderActive();
|
| 4 |
+
return new Promise((resolve, reject) => {
|
| 5 |
+
const script = document.createElement("script");
|
| 6 |
+
let settled = false, timeout = 0, untrack = () => {};
|
| 7 |
+
const finish = (error) => {
|
| 8 |
+
if (settled) return;
|
| 9 |
+
settled = true;
|
| 10 |
+
clearTimeout(timeout);
|
| 11 |
+
untrack();
|
| 12 |
+
script.onload = script.onerror = null;
|
| 13 |
+
if (error) {
|
| 14 |
+
script.remove();
|
| 15 |
+
reject(error);
|
| 16 |
+
} else resolve();
|
| 17 |
+
};
|
| 18 |
+
const networkError = () => Object.assign(
|
| 19 |
+
new Error("阅读组件下载失败,请检查网络后重试加载,或下载原文件。"),
|
| 20 |
+
{ code: "READER_ENGINE_NETWORK" }
|
| 21 |
+
);
|
| 22 |
+
untrack = readerContext.trackReaderResource(() => finish(readerContext.readerAbortError()));
|
| 23 |
+
// Download sibling engines concurrently, execute in dependency order.
|
| 24 |
+
script.async = false;
|
| 25 |
+
script.src = url;
|
| 26 |
+
script.onload = () => finish();
|
| 27 |
+
script.onerror = () => finish(networkError());
|
| 28 |
+
timeout = setTimeout(() => finish(networkError()), 20000);
|
| 29 |
+
document.head.appendChild(script);
|
| 30 |
+
});
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function fetchWithReaderTimeout(url, timeoutMs = readerContext.READER_PROXY_TIMEOUT_MS, requestInit = {}) {
|
| 34 |
+
return readerContext.readerRequestManager.request(url, timeoutMs, requestInit);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function retryableReaderProxyError(error) {
|
| 38 |
+
return [408, 429, 500, 502, 503, 504].includes(error?.status) ||
|
| 39 |
+
["TypeError", "TimeoutError"].includes(error?.name) ||
|
| 40 |
+
/failed to fetch|network|timeout|HTTP\s*(?:408|429|5\d\d)|server response\s*\((?:408|429|5\d\d)\)/i.test(error?.message || "");
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
async function retryReaderProxy(open) {
|
| 44 |
+
for (let attempt = 0; ; attempt++) {
|
| 45 |
+
readerContext.assertReaderActive();
|
| 46 |
+
try {
|
| 47 |
+
return await open();
|
| 48 |
+
} catch (error) {
|
| 49 |
+
if (attempt >= 2 || !readerContext.retryableReaderProxyError(error)) throw error;
|
| 50 |
+
await readerContext.waitForReader(350 * (attempt + 1));
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function fetchReaderUrl(rawUrl, requestInit = {}) {
|
| 56 |
+
if (String(rawUrl).includes("/api/reader-bucket-resource?"))
|
| 57 |
+
return readerContext.fetchWithReaderTimeout(rawUrl, readerContext.READER_PROXY_TIMEOUT_MS, requestInit);
|
| 58 |
+
const proxyUrl = readerContext.readerContentUrl(rawUrl);
|
| 59 |
+
return readerContext.retryReaderProxy(async () => {
|
| 60 |
+
const response = await readerContext.fetchWithReaderTimeout(proxyUrl, readerContext.READER_PROXY_TIMEOUT_MS, requestInit);
|
| 61 |
+
if (response.ok) return response;
|
| 62 |
+
await response.body?.cancel();
|
| 63 |
+
throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status });
|
| 64 |
+
}).catch((error) => {
|
| 65 |
+
readerContext.assertReaderActive();
|
| 66 |
+
requestInit.signal?.throwIfAborted();
|
| 67 |
+
if (readerContext.retryableReaderProxyError(error)) throw error;
|
| 68 |
+
return readerContext.fetchWithReaderTimeout(rawUrl, readerContext.READER_PROXY_TIMEOUT_MS, requestInit);
|
| 69 |
+
});
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function fetchReaderResponse() {
|
| 73 |
+
const early = window.__VOICE_PDF_PRELOAD__;
|
| 74 |
+
if (early?.manifest && early.manifestUrl === new URL(readerContext.sourceUrl, location.href).href) {
|
| 75 |
+
const pending = early.manifest;
|
| 76 |
+
early.manifest = null;
|
| 77 |
+
return pending.then(
|
| 78 |
+
(response) => (response?.ok ? response : readerContext.fetchReaderUrl(readerContext.sourceUrl)),
|
| 79 |
+
() => readerContext.fetchReaderUrl(readerContext.sourceUrl)
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
return readerContext.fetchReaderUrl(readerContext.sourceUrl);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function prepareReaderDocument(engineTasks) {
|
| 86 |
+
const engines = Promise.all(engineTasks);
|
| 87 |
+
const response = Promise.race([readerContext.fetchReaderResponse(), engines.then(() => new Promise(() => {}))]);
|
| 88 |
+
// Rendering owns both promises; early rejection must not become unhandled.
|
| 89 |
+
response.catch(() => {});
|
| 90 |
+
engines.catch(() => {});
|
| 91 |
+
return {response, engines};
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function readPreparedBytes(response, engines, limit = VoiceOfMLReaderSecurity.LIMITS.documentBytes) {
|
| 95 |
+
return Promise.all([Promise.resolve(response).then(response => {
|
| 96 |
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 97 |
+
return VoiceOfMLReaderSecurity.readBytes(response,limit);
|
| 98 |
+
}), engines]).then(([bytes]) => bytes);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function loadTextDocument() {
|
| 102 |
+
return readerContext.fetchReaderResponse().then((response) => ({ response }));
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function loadMarkdownDocument() {
|
| 106 |
+
return readerContext.prepareReaderDocument([readerContext.loadScript(readerContext.MARKED_URL), readerContext.loadScript(readerContext.PURIFY_URL)]);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function loadHtmlDocument() {
|
| 110 |
+
const {response, engines} = readerContext.prepareReaderDocument([readerContext.loadScript(readerContext.PURIFY_URL)]);
|
| 111 |
+
return {response, engine:engines};
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function loadDocxDocument() {
|
| 115 |
+
return readerContext.prepareReaderDocument([readerContext.loadScript(readerContext.JSZIP_URL), readerContext.loadScript(readerContext.DOCX_PREVIEW_URL)]);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
function loadMediaDocument() {
|
| 119 |
+
return Promise.resolve(null);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
async function loadImageDocument(target = readerContext.sourceUrl) {
|
| 123 |
+
const deadline = performance.now() + readerContext.READER_PROXY_TIMEOUT_MS;
|
| 124 |
+
const image = new Image();
|
| 125 |
+
image.className = "reader-image";
|
| 126 |
+
image.alt = readerContext.documentState.title;
|
| 127 |
+
image.decoding = "async";
|
| 128 |
+
try {
|
| 129 |
+
return await readerContext.imageLoader.load(image, readerContext.readerContentUrl(target), readerContext.readerAbortController.signal, deadline);
|
| 130 |
+
} catch (error) {
|
| 131 |
+
readerContext.assertReaderActive();
|
| 132 |
+
if (error?.code === "READER_RESOURCE_LIMIT") throw error;
|
| 133 |
+
return readerContext.imageLoader.load(image, target, readerContext.readerAbortController.signal, deadline);
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function loadChapterManifestDocument() {
|
| 138 |
+
const target = readerContext.chapterManifestUrl || readerContext.sourceUrl, early = window.__VOICE_READER_CHAPTER_PRELOAD__;
|
| 139 |
+
if (early?.manifest && early.manifestUrl === new URL(target, location.href).href) {
|
| 140 |
+
const pending = early.manifest;
|
| 141 |
+
early.manifest = null;
|
| 142 |
+
return pending.then(response => response.ok ? response : readerContext.fetchReaderUrl(target), () => readerContext.fetchReaderUrl(target));
|
| 143 |
+
}
|
| 144 |
+
return readerContext.fetchReaderUrl(target);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
return {
|
| 148 |
+
loadScript,
|
| 149 |
+
fetchWithReaderTimeout,
|
| 150 |
+
retryableReaderProxyError,
|
| 151 |
+
retryReaderProxy,
|
| 152 |
+
fetchReaderUrl,
|
| 153 |
+
fetchReaderResponse,
|
| 154 |
+
prepareReaderDocument,
|
| 155 |
+
readPreparedBytes,
|
| 156 |
+
loadTextDocument,
|
| 157 |
+
loadMarkdownDocument,
|
| 158 |
+
loadHtmlDocument,
|
| 159 |
+
loadDocxDocument,
|
| 160 |
+
loadMediaDocument,
|
| 161 |
+
loadImageDocument,
|
| 162 |
+
loadChapterManifestDocument
|
| 163 |
+
};
|
| 164 |
+
}
|
static/reader/core/reader-lifecycle.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function createReaderLifecycle(readerContext) {
|
| 2 |
+
function readerAbortError() {
|
| 3 |
+
return new DOMException("Reader work cancelled", "AbortError");
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
function assertReaderActive() {
|
| 7 |
+
if (readerContext.readerAbortController.signal.aborted || readerContext.readerRuntime.state.lifecycle.disposed)
|
| 8 |
+
throw readerContext.readerAbortError();
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function trackReaderResource(cleanup) {
|
| 12 |
+
if (readerContext.readerAbortController.signal.aborted) cleanup();
|
| 13 |
+
else readerContext.readerResources.add(cleanup);
|
| 14 |
+
return () => readerContext.readerResources.delete(cleanup);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function stopReaderWork() {
|
| 18 |
+
readerContext.readerAbortController.abort();
|
| 19 |
+
for (const cleanup of readerContext.readerResources) {
|
| 20 |
+
try {
|
| 21 |
+
cleanup();
|
| 22 |
+
} catch (_) {}
|
| 23 |
+
}
|
| 24 |
+
readerContext.readerResources.clear();
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function awaitReader(promise, extraSignal = null) {
|
| 28 |
+
return new Promise((resolve, reject) => {
|
| 29 |
+
let settled = false;
|
| 30 |
+
const signals = extraSignal ? [readerContext.readerAbortController.signal, extraSignal] : [readerContext.readerAbortController.signal];
|
| 31 |
+
const finish = (callback, value) => {
|
| 32 |
+
if (settled) return;
|
| 33 |
+
settled = true;
|
| 34 |
+
signals.forEach(signal => signal.removeEventListener("abort", abort));
|
| 35 |
+
callback(value);
|
| 36 |
+
};
|
| 37 |
+
const abort = () => finish(reject, readerContext.readerAbortError());
|
| 38 |
+
Promise.resolve(promise)
|
| 39 |
+
.then(value => finish(resolve, value), error => finish(reject, error));
|
| 40 |
+
if (signals.some(signal => signal.aborted)) abort();
|
| 41 |
+
else signals.forEach(signal => signal.addEventListener("abort", abort, { once: true }));
|
| 42 |
+
});
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function waitForReader(delay = 0, animationFrame = false) {
|
| 46 |
+
return new Promise((resolve, reject) => {
|
| 47 |
+
readerContext.assertReaderActive();
|
| 48 |
+
const signal = readerContext.readerAbortController.signal;
|
| 49 |
+
const abort = () => {
|
| 50 |
+
if (animationFrame) cancelAnimationFrame(handle);
|
| 51 |
+
else clearTimeout(handle);
|
| 52 |
+
reject(readerContext.readerAbortError());
|
| 53 |
+
};
|
| 54 |
+
const done = () => {
|
| 55 |
+
signal.removeEventListener("abort", abort);
|
| 56 |
+
resolve();
|
| 57 |
+
};
|
| 58 |
+
const handle = animationFrame ? requestAnimationFrame(done) : setTimeout(done, delay);
|
| 59 |
+
signal.addEventListener("abort", abort, { once: true });
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function setReaderPhase(phase) {
|
| 64 |
+
return readerContext.readerRuntime.setPhase(phase);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function setReaderStage(stage) {
|
| 68 |
+
return readerContext.readerRuntime.setStage(stage);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function classifyReaderError(error, fallback = "READER_PARSE") {
|
| 72 |
+
if (["READER_ENGINE_NETWORK", "READER_RESTORE"].includes(error?.code)) return error.code;
|
| 73 |
+
const value = `${error?.name || ""} ${error?.message || error || ""}`;
|
| 74 |
+
if (/AbortError|timeout|network|fetch|HTTP\s*\d+/i.test(value)) return "READER_NETWORK";
|
| 75 |
+
if (
|
| 76 |
+
/EPUB_INVALID|corrupt|damage|truncated|central directory|end of data|invalid zip/i.test(value)
|
| 77 |
+
)
|
| 78 |
+
return "READER_CORRUPT";
|
| 79 |
+
return fallback;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
function markReaderContentReady() {
|
| 83 |
+
if (["foliate", "epub-chapters"].includes(readerContext.capability?.mode) && readerContext.loadingIndicator.isConnected)
|
| 84 |
+
readerContext.foliateScrollAnchors.preserve(() => readerContext.loadingIndicator.remove());
|
| 85 |
+
else readerContext.loadingIndicator.remove();
|
| 86 |
+
readerContext.loadingStatus.hidden = true;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function nextReaderGeneration(name) {
|
| 90 |
+
const generation = readerContext.readerRuntime.nextGeneration(name);
|
| 91 |
+
if (name === "search") readerContext.drainPdfTextWaiters();
|
| 92 |
+
return generation;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function isReaderGenerationCurrent(name, value) {
|
| 96 |
+
return !readerContext.readerAbortController.signal.aborted && readerContext.readerRuntime.isCurrent(name, value);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function reportNavigationError(error, generation) {
|
| 100 |
+
if (readerContext.isReaderGenerationCurrent("navigation", generation) && error?.name !== "AbortError")
|
| 101 |
+
console.warn("Reader navigation failed", error);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function disposeReader() {
|
| 105 |
+
if (readerContext.readerLifecycle.disposed) return;
|
| 106 |
+
readerContext.updateDocumentState({ restorationReady: false });
|
| 107 |
+
readerContext.readerRuntime.dispose();
|
| 108 |
+
readerContext.contentSanitizerObserver?.disconnect();
|
| 109 |
+
readerContext.contentSanitizerObserver = null;
|
| 110 |
+
clearTimeout(readerContext.saveTimer);
|
| 111 |
+
clearTimeout(readerContext.themeAnimationTimer);
|
| 112 |
+
clearTimeout(readerContext.panelAnimationTimer);
|
| 113 |
+
for (const frame of [readerContext.markerFrame, readerContext.foliateScrollFrame, readerContext.foliateWindowFrame, readerContext.epubSeekFrame])
|
| 114 |
+
if (frame) cancelAnimationFrame(frame);
|
| 115 |
+
readerContext.saveTimer =
|
| 116 |
+
readerContext.themeAnimationTimer =
|
| 117 |
+
readerContext.panelAnimationTimer =
|
| 118 |
+
readerContext.markerFrame =
|
| 119 |
+
readerContext.foliateScrollFrame =
|
| 120 |
+
readerContext.foliateWindowFrame =
|
| 121 |
+
readerContext.epubSeekFrame =
|
| 122 |
+
0;
|
| 123 |
+
VoiceOfMLReaderStore.dispose?.();
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function fail(message, code = "READER_PARSE") {
|
| 127 |
+
if (readerContext.readerLifecycle.disposed || readerContext.readerAbortController.signal.aborted) return;
|
| 128 |
+
readerContext.updateDocumentState({ restorationReady: false });
|
| 129 |
+
readerContext.readerRuntime.fail(code);
|
| 130 |
+
readerContext.stopReaderWork();
|
| 131 |
+
readerContext.readerRequestManager.dispose();
|
| 132 |
+
readerContext.formatAdapters.dispose();
|
| 133 |
+
window.__VOICE_PDF_PRELOAD__?.dispose();
|
| 134 |
+
readerContext.content.dataset.errorCode = code;
|
| 135 |
+
readerContext.loadingStatus.hidden = true;
|
| 136 |
+
readerContext.markReaderContentReady();
|
| 137 |
+
const visibleMessage = `${message} [${readerContext.readerLifecycle.stage}]`;
|
| 138 |
+
readerContext.content.innerHTML = `<div class="reader-error"></div>`;
|
| 139 |
+
readerContext.content.querySelector(".reader-error").textContent = visibleMessage;
|
| 140 |
+
if (["READER_ENGINE_NETWORK", "READER_RESTORE"].includes(code)) {
|
| 141 |
+
const retry = document.createElement("button");
|
| 142 |
+
retry.type = "button";
|
| 143 |
+
retry.className = "text-button";
|
| 144 |
+
retry.id = "reader-engine-retry";
|
| 145 |
+
retry.textContent = "重试加载";
|
| 146 |
+
// Reload clears the browser's failed module map and preserves this book URL.
|
| 147 |
+
retry.addEventListener("click", () => location.reload());
|
| 148 |
+
readerContext.content.querySelector(".reader-error").append(document.createElement("br"), retry);
|
| 149 |
+
}
|
| 150 |
+
readerContext.status.textContent = "无法打开";
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
return {
|
| 154 |
+
readerAbortError,
|
| 155 |
+
assertReaderActive,
|
| 156 |
+
trackReaderResource,
|
| 157 |
+
stopReaderWork,
|
| 158 |
+
awaitReader,
|
| 159 |
+
waitForReader,
|
| 160 |
+
setReaderPhase,
|
| 161 |
+
setReaderStage,
|
| 162 |
+
classifyReaderError,
|
| 163 |
+
markReaderContentReady,
|
| 164 |
+
nextReaderGeneration,
|
| 165 |
+
isReaderGenerationCurrent,
|
| 166 |
+
reportNavigationError,
|
| 167 |
+
disposeReader,
|
| 168 |
+
fail
|
| 169 |
+
};
|
| 170 |
+
}
|
static/{reader-request-manager.js → reader/core/reader-request-manager.js}
RENAMED
|
File without changes
|
static/{reader-runtime.js → reader/core/reader-runtime.js}
RENAMED
|
File without changes
|
static/{reader-security.js → reader/core/reader-security.js}
RENAMED
|
File without changes
|
static/reader/core/reader-source.mjs
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function createReaderSource(readerContext) {
|
| 2 |
+
function readerContentUrl(url) {
|
| 3 |
+
try {
|
| 4 |
+
const parsed = new URL(url, location.href);
|
| 5 |
+
if (parsed.origin === location.origin && parsed.pathname === "/api/reader-bucket-resource")
|
| 6 |
+
return parsed.href;
|
| 7 |
+
if (parsed.origin === "https://huggingface.co" && !parsed.username && !parsed.password && !parsed.search && !parsed.hash) {
|
| 8 |
+
const prefix = /^\/buckets\/(vomebook\/(?:pdf-pages-v2|reader-assets-v2))\/resolve\//.exec(parsed.pathname);
|
| 9 |
+
const path = prefix && decodeURIComponent(parsed.pathname.slice(prefix[0].length));
|
| 10 |
+
if (path && VoiceOfMLReader.isBucketPath(path, true, prefix[1]))
|
| 11 |
+
return `/api/reader-bucket-resource?path=${encodeURIComponent(path)}`;
|
| 12 |
+
}
|
| 13 |
+
} catch (_) {}
|
| 14 |
+
return `/api/reader-content?url=${encodeURIComponent(url)}`;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function readerDownloadApiUrl(path) {
|
| 18 |
+
return path;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function readerDownloadUrl(filename, link) {
|
| 22 |
+
return readerContext.readerDownloadApiUrl(`/api/download?file=${encodeURIComponent(filename || "file")}&link=${encodeURIComponent(link || "")}`);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
async function startReaderDownload(event) {
|
| 26 |
+
event.preventDefault();
|
| 27 |
+
const button = event.currentTarget;
|
| 28 |
+
if (button.hasAttribute("aria-busy")) return;
|
| 29 |
+
readerContext.showDownloadFeedback("正在准备下载…", 0);
|
| 30 |
+
const controller = new AbortController();
|
| 31 |
+
let timer;
|
| 32 |
+
button.setAttribute("aria-busy", "true");
|
| 33 |
+
try {
|
| 34 |
+
// ID-only links share the Reader's in-flight resolution, not its render phase.
|
| 35 |
+
if (!readerContext.downloadUrl) await readerContext.readerSourcePromise;
|
| 36 |
+
if (!readerContext.downloadUrl) throw new Error("无法获取原文件地址");
|
| 37 |
+
const filename = readerContext.documentState.title || "file";
|
| 38 |
+
const checkUrl = readerContext.readerDownloadApiUrl(`/api/download/check?link=${encodeURIComponent(readerContext.downloadUrl)}`);
|
| 39 |
+
timer = setTimeout(() => controller.abort(), 8000);
|
| 40 |
+
const response = await fetch(checkUrl, { signal: controller.signal, cache: "no-store" });
|
| 41 |
+
const result = await response.json().catch(() => null);
|
| 42 |
+
if (!response.ok || result?.ok !== true) throw new Error(result?.error || "下载检查失败,请重试");
|
| 43 |
+
if (readerContext.readerLifecycle.disposed) return;
|
| 44 |
+
const frame = document.createElement("iframe");
|
| 45 |
+
frame.hidden = true;
|
| 46 |
+
frame.setAttribute("aria-hidden", "true");
|
| 47 |
+
frame.src = readerContext.readerDownloadUrl(filename, readerContext.downloadUrl);
|
| 48 |
+
document.body.appendChild(frame);
|
| 49 |
+
setTimeout(() => frame.remove(), 60000);
|
| 50 |
+
readerContext.showDownloadFeedback("已发起下载");
|
| 51 |
+
} catch (error) {
|
| 52 |
+
if (!readerContext.readerLifecycle.disposed) {
|
| 53 |
+
readerContext.showDownloadFeedback(error?.name === "AbortError" ? "下载检查超时,请重试" : (error.message || "下载失败,请重试"));
|
| 54 |
+
}
|
| 55 |
+
} finally {
|
| 56 |
+
clearTimeout(timer);
|
| 57 |
+
button.removeAttribute("aria-busy");
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function normalizeSourceUrl(url) {
|
| 62 |
+
return url.startsWith("/api/reader-bucket-resource?") ? new URL(url, location.origin).href : url;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async function resolveReaderId(id) {
|
| 66 |
+
const endpoint = `/api/reader-resolve?id=${encodeURIComponent(id)}`;
|
| 67 |
+
for (let attempt = 0; attempt < 2; attempt++) {
|
| 68 |
+
const early = window.__VOICE_READER_RESOLVE__;
|
| 69 |
+
if (!attempt && early?.id === id) {
|
| 70 |
+
window.__VOICE_READER_RESOLVE__ = null;
|
| 71 |
+
const untrack = readerContext.trackReaderResource(early.dispose);
|
| 72 |
+
let result;
|
| 73 |
+
try { result = await readerContext.awaitReader(early.promise); }
|
| 74 |
+
finally { untrack(); early.dispose(); }
|
| 75 |
+
if (result.ok) return result.data;
|
| 76 |
+
if (![408, 429, 500, 502, 503, 504].includes(result.status))
|
| 77 |
+
throw new Error(`HTTP ${result.status}`);
|
| 78 |
+
await readerContext.waitForReader(350);
|
| 79 |
+
continue;
|
| 80 |
+
}
|
| 81 |
+
const response = await readerContext.readerRequestManager.request(endpoint, readerContext.READER_PROXY_TIMEOUT_MS);
|
| 82 |
+
if (response.ok) return response.json();
|
| 83 |
+
if (![408, 429, 500, 502, 503, 504].includes(response.status) || attempt)
|
| 84 |
+
throw new Error(`HTTP ${response.status}`);
|
| 85 |
+
await response.body?.cancel();
|
| 86 |
+
await readerContext.waitForReader(350);
|
| 87 |
+
}
|
| 88 |
+
throw new Error("Reader ID resolution failed");
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function applyReaderMetadata(data) {
|
| 92 |
+
if (data) readerContext.resolvedReaderData = { ...(readerContext.resolvedReaderData || {}), ...data };
|
| 93 |
+
if (data?.ReaderOcrManifest || data?.ocr_manifest) {
|
| 94 |
+
readerContext.ocrManifestUrl = data.ReaderOcrManifest || data.ocr_manifest;
|
| 95 |
+
readerContext.params.set("ocr_manifest", readerContext.ocrManifestUrl);
|
| 96 |
+
}
|
| 97 |
+
readerContext.readerRuntime.update("source", { metadata: readerContext.resolvedReaderData });
|
| 98 |
+
const title = String(readerContext.resolvedReaderData?.title || readerContext.params.get("title") || readerContext.sourceName).trim();
|
| 99 |
+
const suffix = String(
|
| 100 |
+
readerContext.resolvedReaderData?.original_extension || (readerContext.extension === "pdf-pages" ? "pdf" : readerContext.extension)
|
| 101 |
+
)
|
| 102 |
+
.toLowerCase()
|
| 103 |
+
.replace(/^\./, "");
|
| 104 |
+
const value = title + (suffix && !title.toLowerCase().endsWith(`.${suffix}`) ? `.${suffix}` : "");
|
| 105 |
+
readerContext.updateDocumentState({ title: value });
|
| 106 |
+
document.querySelector("#title").textContent = value;
|
| 107 |
+
document.title = `${value} - VoiceOfML Reader`;
|
| 108 |
+
readerContext.applyReaderPathMetadata(readerContext.resolvedReaderData);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function applyReaderPathMetadata(data) {
|
| 112 |
+
const folder = Array.isArray(data?.folder) ? data.folder.join("/") : String(data?.folder || "");
|
| 113 |
+
const value = String(
|
| 114 |
+
data?.path || [data?.repo, folder].filter(Boolean).join("/") || readerContext.readerPathLabel
|
| 115 |
+
).replace(/^\/+|\/+$/g, "");
|
| 116 |
+
readerContext.folderNavigationTarget = null;
|
| 117 |
+
try {
|
| 118 |
+
const target = new URL(readerContext.folderReturnUrl, location.origin);
|
| 119 |
+
if (
|
| 120 |
+
readerContext.folderReturnUrl &&
|
| 121 |
+
target.origin === location.origin &&
|
| 122 |
+
target.pathname.split("/").filter(Boolean).length === 1
|
| 123 |
+
)
|
| 124 |
+
readerContext.folderNavigationTarget = target;
|
| 125 |
+
} catch (_) {}
|
| 126 |
+
if (!readerContext.folderNavigationTarget && data?.repo) {
|
| 127 |
+
readerContext.folderNavigationTarget = new URL("/" + encodeURIComponent(data.repo), location.origin);
|
| 128 |
+
if (folder) readerContext.folderNavigationTarget.searchParams.set("folder_self", folder);
|
| 129 |
+
}
|
| 130 |
+
readerContext.readerPath.textContent = value;
|
| 131 |
+
readerContext.readerPath.hidden = !value;
|
| 132 |
+
readerContext.readerPath.disabled = !readerContext.folderNavigationTarget;
|
| 133 |
+
readerContext.readerPath.setAttribute("aria-label", `筛选文件夹:${value}`);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function validSource(raw) {
|
| 137 |
+
try {
|
| 138 |
+
const url = new URL(raw);
|
| 139 |
+
const bucketPath = url.searchParams.get("path") || "";
|
| 140 |
+
if (
|
| 141 |
+
(url.origin === location.origin || url.origin === "https://voiceofml-search.hf.space") &&
|
| 142 |
+
url.pathname === "/api/reader-bucket-resource" &&
|
| 143 |
+
url.searchParams.getAll("path").length === 1 &&
|
| 144 |
+
[...url.searchParams.keys()].every((key) => key === "path") &&
|
| 145 |
+
(VoiceOfMLReader.isBucketPath(bucketPath, true, "vomebook/pdf-pages-v2") ||
|
| 146 |
+
VoiceOfMLReader.isBucketPath(bucketPath, true, "vomebook/reader-assets-v2"))
|
| 147 |
+
)
|
| 148 |
+
return true;
|
| 149 |
+
if (url.protocol === "https:" && url.hostname === "huggingface.co" &&
|
| 150 |
+
(url.pathname.startsWith("/buckets/vomebook/pdf-pages-v2/resolve/") ||
|
| 151 |
+
url.pathname.startsWith("/buckets/vomebook/reader-assets-v2/resolve/")) && !url.search && !url.hash) {
|
| 152 |
+
const prefix = url.pathname.startsWith("/buckets/vomebook/pdf-pages-v2/resolve/")
|
| 153 |
+
? "/buckets/vomebook/pdf-pages-v2/resolve/"
|
| 154 |
+
: "/buckets/vomebook/reader-assets-v2/resolve/";
|
| 155 |
+
const path = decodeURIComponent(url.pathname.slice(prefix.length));
|
| 156 |
+
const bucket = prefix.includes("pdf-pages-v2") ? "vomebook/pdf-pages-v2" : "vomebook/reader-assets-v2";
|
| 157 |
+
return VoiceOfMLReader.isBucketPath(path, true, bucket);
|
| 158 |
+
}
|
| 159 |
+
if (url.protocol !== "https:" || !["huggingface.co", "hf-mirror.com"].includes(url.hostname))
|
| 160 |
+
return false;
|
| 161 |
+
const readerAsset = VoiceOfMLReader.isAssetSourcePath(url.pathname);
|
| 162 |
+
if (readerContext.extension === "docx") return readerAsset;
|
| 163 |
+
return /^\/datasets\/VoiceOfML\/[^/]+\/(resolve|raw)\//.test(url.pathname) || readerAsset;
|
| 164 |
+
} catch (_) {
|
| 165 |
+
return false;
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function validOcr(raw) {
|
| 170 |
+
try {
|
| 171 |
+
const url = new URL(raw, location.origin);
|
| 172 |
+
return url.origin === location.origin && url.pathname.startsWith("/txt/");
|
| 173 |
+
} catch (_) {
|
| 174 |
+
return false;
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function validFallback(raw) {
|
| 179 |
+
try {
|
| 180 |
+
const url = new URL(raw);
|
| 181 |
+
return (
|
| 182 |
+
url.protocol === "https:" &&
|
| 183 |
+
["huggingface.co", "hf-mirror.com"].includes(url.hostname) &&
|
| 184 |
+
/\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:linearized\.pdf|(?:[a-z0-9-]+\/)?document\.pdf)$/.test(
|
| 185 |
+
url.pathname
|
| 186 |
+
)
|
| 187 |
+
);
|
| 188 |
+
} catch (_) {
|
| 189 |
+
return false;
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
function readerAssetObjectFamily(raw, base = location.href) {
|
| 194 |
+
try {
|
| 195 |
+
const url = new URL(raw, base);
|
| 196 |
+
if (url.origin === "https://voiceofml-search.hf.space" && url.pathname === "/api/reader-bucket-resource") {
|
| 197 |
+
const bucketPath = url.searchParams.get("path") || "";
|
| 198 |
+
const match = bucketPath.match(/^(chapters\/ebook\/(?:epub|mobi|azw3|fb2|chm)\/[0-9a-f]{64}\/[0-9a-f]{16}\/)/);
|
| 199 |
+
if (url.hash || url.searchParams.getAll("path").length !== 1 ||
|
| 200 |
+
[...url.searchParams.keys()].some((key) => key !== "path") || !match ||
|
| 201 |
+
!VoiceOfMLReader.isBucketPath(bucketPath, true, "vomebook/reader-assets-v2")) return null;
|
| 202 |
+
return { url, prefix: match[1], bucket: true, bucketPath };
|
| 203 |
+
}
|
| 204 |
+
if (
|
| 205 |
+
url.protocol !== "https:" ||
|
| 206 |
+
!["huggingface.co", "hf-mirror.com"].includes(url.hostname) ||
|
| 207 |
+
url.username ||
|
| 208 |
+
url.password
|
| 209 |
+
)
|
| 210 |
+
return null;
|
| 211 |
+
const match = url.pathname.match(
|
| 212 |
+
/^\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/(objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[a-z0-9-]+\/)?)/
|
| 213 |
+
);
|
| 214 |
+
return match
|
| 215 |
+
? { url, prefix: url.pathname.slice(0, url.pathname.indexOf(match[1])) + match[1], bucket: false }
|
| 216 |
+
: null;
|
| 217 |
+
} catch (_) {
|
| 218 |
+
return null;
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
function trustedReaderAssetManifestUrl(raw) {
|
| 223 |
+
const family = readerContext.readerAssetObjectFamily(raw);
|
| 224 |
+
const manifestPath = family?.bucket ? family.bucketPath : family?.url.pathname;
|
| 225 |
+
return family &&
|
| 226 |
+
(family.bucket || !family.url.search) &&
|
| 227 |
+
(family.bucket || !family.url.hash) &&
|
| 228 |
+
/\/chapter-manifest\.json$/i.test(manifestPath || "")
|
| 229 |
+
? family.url.href
|
| 230 |
+
: null;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
function trustedChapterUrl(raw, base) {
|
| 234 |
+
const manifest = readerContext.readerAssetObjectFamily(base);
|
| 235 |
+
const manifestPath = manifest?.bucket ? manifest.bucketPath : new URL(base, location.href).pathname;
|
| 236 |
+
if (!manifest || !/\/chapter-manifest\.json$/i.test(manifestPath))
|
| 237 |
+
return null;
|
| 238 |
+
try {
|
| 239 |
+
if (manifest.bucket) {
|
| 240 |
+
const url = new URL(String(raw || ""), `https://bucket.invalid/${manifest.bucketPath}`);
|
| 241 |
+
const path = decodeURIComponent(url.pathname.slice(1));
|
| 242 |
+
if (url.origin !== "https://bucket.invalid" || url.search || url.hash ||
|
| 243 |
+
!path.startsWith(manifest.prefix) ||
|
| 244 |
+
!/(?:\/chapters\/chapter-[0-9]{4}\.xhtml|\/epub-search-index\.json\.gz)$/i.test(path) ||
|
| 245 |
+
!VoiceOfMLReader.isBucketPath(path, true, "vomebook/reader-assets-v2")) return null;
|
| 246 |
+
return `${manifest.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`;
|
| 247 |
+
}
|
| 248 |
+
const url = new URL(raw, base);
|
| 249 |
+
return !url.search &&
|
| 250 |
+
!url.hash &&
|
| 251 |
+
url.origin === manifest.url.origin &&
|
| 252 |
+
url.pathname.startsWith(manifest.prefix) &&
|
| 253 |
+
/(?:\.(?:xhtml?|html?)|\/epub-search-index\.json\.gz)$/i.test(url.pathname)
|
| 254 |
+
? url.href
|
| 255 |
+
: null;
|
| 256 |
+
} catch (_) {
|
| 257 |
+
return null;
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
function trustedChapterReferenceUrl(raw, base, manifestBase) {
|
| 262 |
+
const family = readerContext.readerAssetObjectFamily(manifestBase);
|
| 263 |
+
if (!family) return null;
|
| 264 |
+
try {
|
| 265 |
+
if (family.bucket) {
|
| 266 |
+
const current = readerContext.readerAssetObjectFamily(base);
|
| 267 |
+
if (!current?.bucket) return null;
|
| 268 |
+
const url = new URL(String(raw || ""), `https://bucket.invalid/${current.bucketPath}`);
|
| 269 |
+
const path = decodeURIComponent(url.pathname.slice(1));
|
| 270 |
+
if (url.origin !== "https://bucket.invalid" || url.search ||
|
| 271 |
+
!path.startsWith(family.prefix) || !/\/chapters\/chapter-[0-9]{4}\.xhtml$/i.test(path) ||
|
| 272 |
+
!VoiceOfMLReader.isBucketPath(path, true, "vomebook/reader-assets-v2")) return null;
|
| 273 |
+
return { url: `${family.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`,
|
| 274 |
+
fragment: url.hash };
|
| 275 |
+
}
|
| 276 |
+
const url = new URL(raw, base);
|
| 277 |
+
return !url.search && url.origin === family.url.origin && url.pathname.startsWith(family.prefix) &&
|
| 278 |
+
/\/(?:chapters\/)?[^/]+\.xhtml?$/i.test(url.pathname)
|
| 279 |
+
? { url: url.href, fragment: url.hash } : null;
|
| 280 |
+
} catch (_) {
|
| 281 |
+
return null;
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
function trustedChapterResourceUrl(raw, base, attribute, manifestBase) {
|
| 286 |
+
const value = String(raw || "").trim();
|
| 287 |
+
if (!value || /^(?:data:|mailto:|tel:|javascript:)/i.test(value)) return null;
|
| 288 |
+
// Chapters and resources can be siblings within the manifest's asset directory.
|
| 289 |
+
const family = readerContext.readerAssetObjectFamily(manifestBase);
|
| 290 |
+
if (!family) return null;
|
| 291 |
+
try {
|
| 292 |
+
if (family.bucket) {
|
| 293 |
+
const current = readerContext.readerAssetObjectFamily(base);
|
| 294 |
+
if (!current?.bucket) return null;
|
| 295 |
+
const url = new URL(value, `https://bucket.invalid/${current.bucketPath}`);
|
| 296 |
+
const path = decodeURIComponent(url.pathname.slice(1));
|
| 297 |
+
if (url.origin !== "https://bucket.invalid" || url.search || url.hash ||
|
| 298 |
+
!path.startsWith(family.prefix) || !VoiceOfMLReader.isBucketPath(path, true, "vomebook/reader-assets-v2")) return null;
|
| 299 |
+
const resource = `${family.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`;
|
| 300 |
+
return attribute === "href" ? resource : resource;
|
| 301 |
+
}
|
| 302 |
+
const url = new URL(value, base),
|
| 303 |
+
path = url.pathname.toLowerCase();
|
| 304 |
+
if (url.origin !== family.url.origin || !url.pathname.startsWith(family.prefix)) return null;
|
| 305 |
+
const allowed =
|
| 306 |
+
attribute === "href"
|
| 307 |
+
? /\.(?:x?html?|css|svg|png|jpe?g|gif|webp|woff2?|ttf|otf|mp3|mp4|m4a|webm|ogg)$/i.test(
|
| 308 |
+
path
|
| 309 |
+
)
|
| 310 |
+
: /\.(?:svg|png|jpe?g|gif|webp|woff2?|ttf|otf|mp3|mp4|m4a|webm|ogg)$/i.test(path);
|
| 311 |
+
return allowed ? url.href : null;
|
| 312 |
+
} catch (_) {
|
| 313 |
+
return null;
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
return {
|
| 318 |
+
readerContentUrl,
|
| 319 |
+
readerDownloadApiUrl,
|
| 320 |
+
readerDownloadUrl,
|
| 321 |
+
startReaderDownload,
|
| 322 |
+
normalizeSourceUrl,
|
| 323 |
+
resolveReaderId,
|
| 324 |
+
applyReaderMetadata,
|
| 325 |
+
applyReaderPathMetadata,
|
| 326 |
+
validSource,
|
| 327 |
+
validOcr,
|
| 328 |
+
validFallback,
|
| 329 |
+
readerAssetObjectFamily,
|
| 330 |
+
trustedReaderAssetManifestUrl,
|
| 331 |
+
trustedChapterUrl,
|
| 332 |
+
trustedChapterReferenceUrl,
|
| 333 |
+
trustedChapterResourceUrl
|
| 334 |
+
};
|
| 335 |
+
}
|
static/{reader-store.js → reader/core/reader-store.js}
RENAMED
|
File without changes
|
static/reader/formats/reader-chapter-format.mjs
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function createReaderChapterFormat(readerContext) {
|
| 2 |
+
async function renderChapterManifest(prepared) {
|
| 3 |
+
const response = await prepared;
|
| 4 |
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 5 |
+
const manifest = VoiceOfMLReaderSecurity.validateChapterManifest(
|
| 6 |
+
JSON.parse(
|
| 7 |
+
await VoiceOfMLReaderSecurity.readText(response, VoiceOfMLReaderSecurity.LIMITS.manifestBytes)
|
| 8 |
+
)
|
| 9 |
+
);
|
| 10 |
+
readerContext.assertReaderActive();
|
| 11 |
+
const frame = document.createElement("div");
|
| 12 |
+
frame.className = "epub-frame";
|
| 13 |
+
frame.style.overflowAnchor = "none";
|
| 14 |
+
const openingGeneration = readerContext.readerRuntime.currentGeneration("navigation");
|
| 15 |
+
readerContext.content.appendChild(frame);
|
| 16 |
+
const loaded = new Set(), retainedBytes = new Map(),
|
| 17 |
+
manifestBase = readerContext.trustedReaderAssetManifestUrl(readerContext.chapterManifestUrl || readerContext.sourceUrl);
|
| 18 |
+
const chapterByIndex = new Map(manifest.chapters.map((chapter) => [chapter.index, chapter]));
|
| 19 |
+
const chapterPrefetchCount = 6;
|
| 20 |
+
const chapterPrefetchMaxCount = 10, chapterPrefetchBytes = 128 * 1024;
|
| 21 |
+
let chapterFocusIndex = 1, chapterWindowFrame = 0, chapterWindowReady = false;
|
| 22 |
+
let chapterNavigating = 0;
|
| 23 |
+
let chapterPreviousObserver = null;
|
| 24 |
+
const chapterBudget = VoiceOfMLReaderSecurity.createByteBudget();
|
| 25 |
+
if (!manifestBase) throw new Error("EPUB_INVALID_MANIFEST_PATH");
|
| 26 |
+
const searchIndex = manifest.search_index;
|
| 27 |
+
readerContext.chapterSearchConfiguration =
|
| 28 |
+
searchIndex?.path === "epub-search-index.json.gz" &&
|
| 29 |
+
Number.isSafeInteger(searchIndex.bytes) &&
|
| 30 |
+
searchIndex.bytes > 0 &&
|
| 31 |
+
/^[0-9a-f]{64}$/.test(searchIndex.sha256 || "")
|
| 32 |
+
? {
|
| 33 |
+
chapters: manifest.chapters,
|
| 34 |
+
index: {
|
| 35 |
+
...searchIndex,
|
| 36 |
+
url: (() => {
|
| 37 |
+
const resolved = readerContext.trustedChapterUrl(searchIndex.path, manifestBase);
|
| 38 |
+
if (!resolved) return "";
|
| 39 |
+
const target = new URL(resolved, location.href);
|
| 40 |
+
const bucketUrl = target.origin === "https://voiceofml-search.hf.space" &&
|
| 41 |
+
target.pathname === "/api/reader-bucket-resource"
|
| 42 |
+
? target.pathname + target.search : resolved;
|
| 43 |
+
return readerContext.readerContentUrl(bucketUrl);
|
| 44 |
+
})()
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
: null;
|
| 48 |
+
const chapterUrl = (chapter) => readerContext.trustedChapterUrl(chapter.path, manifestBase);
|
| 49 |
+
const chapterAt = (index) => chapterByIndex.get(index) || null;
|
| 50 |
+
const nextChapterNode = (index, selector) => {
|
| 51 |
+
for (const node of frame.querySelectorAll(selector))
|
| 52 |
+
if (Number(node.dataset.chapter) > index) return node;
|
| 53 |
+
return null;
|
| 54 |
+
};
|
| 55 |
+
const resourceUrl = (raw, base, attribute) => {
|
| 56 |
+
const value = String(raw || "").trim();
|
| 57 |
+
if (!value || value.startsWith("#")) return null;
|
| 58 |
+
return readerContext.trustedChapterResourceUrl(value, base, attribute, manifestBase);
|
| 59 |
+
};
|
| 60 |
+
const insertChapter = (article) => {
|
| 61 |
+
const existing = frame.querySelector(
|
| 62 |
+
`.reader-epub-chapter[data-chapter="${article.dataset.chapter}"]`
|
| 63 |
+
);
|
| 64 |
+
if (existing) return existing;
|
| 65 |
+
const next = nextChapterNode(Number(article.dataset.chapter), ".reader-epub-chapter, .reader-chapter-sentinel");
|
| 66 |
+
const marker = frame.querySelector(`.reader-chapter-sentinel[data-chapter="${article.dataset.chapter}"]`);
|
| 67 |
+
const insert = () => {
|
| 68 |
+
if (marker) {
|
| 69 |
+
readerContext.chapterManifestObserver.unobserve(marker);
|
| 70 |
+
chapterPreviousObserver.unobserve(marker);
|
| 71 |
+
marker.replaceWith(article);
|
| 72 |
+
} else frame.insertBefore(article, next || null);
|
| 73 |
+
};
|
| 74 |
+
const preservePosition = chapterWindowReady || !readerContext.isReaderGenerationCurrent("navigation", openingGeneration);
|
| 75 |
+
if (preservePosition) readerContext.foliateScrollAnchors.preserve(insert);
|
| 76 |
+
else insert();
|
| 77 |
+
readerContext.chapterManifestObserver.observe(article);
|
| 78 |
+
if (preservePosition) readerContext.foliateScrollAnchors.observe(article);
|
| 79 |
+
return article;
|
| 80 |
+
};
|
| 81 |
+
const ensureSentinel = (chapter, previous = false) => {
|
| 82 |
+
if (readerContext.readerAbortController.signal.aborted || loaded.has(chapter.index)) return null;
|
| 83 |
+
const selector = `.reader-chapter-sentinel[data-chapter="${chapter.index}"]`;
|
| 84 |
+
const existing = frame.querySelector(selector);
|
| 85 |
+
if (existing) return existing;
|
| 86 |
+
const marker = document.createElement("button");
|
| 87 |
+
marker.type = "button";
|
| 88 |
+
marker.className = "reader-chapter-sentinel";
|
| 89 |
+
marker.dataset.chapter = String(chapter.index);
|
| 90 |
+
marker.textContent = `加载第 ${chapter.index} 章`;
|
| 91 |
+
marker.setAttribute("aria-label", `重试加载第 ${chapter.index} 章`);
|
| 92 |
+
marker.addEventListener("click", () => {
|
| 93 |
+
marker.disabled = true;
|
| 94 |
+
fetchChapter(chapter).catch((error) => {
|
| 95 |
+
marker.disabled = false;
|
| 96 |
+
console.warn("EPUB chapter could not be loaded", error);
|
| 97 |
+
});
|
| 98 |
+
});
|
| 99 |
+
const next = nextChapterNode(chapter.index,
|
| 100 |
+
".reader-epub-chapter[data-chapter], .reader-chapter-sentinel[data-chapter]");
|
| 101 |
+
const insert = () => frame.insertBefore(marker, next || null);
|
| 102 |
+
if (chapterWindowReady || !readerContext.isReaderGenerationCurrent("navigation", openingGeneration))
|
| 103 |
+
readerContext.foliateScrollAnchors.preserve(insert);
|
| 104 |
+
else insert();
|
| 105 |
+
(previous ? chapterPreviousObserver : readerContext.chapterManifestObserver).observe(marker);
|
| 106 |
+
return marker;
|
| 107 |
+
};
|
| 108 |
+
const chapterTarget = (article, fragment) =>
|
| 109 |
+
!fragment || article.id === fragment ? article :
|
| 110 |
+
article.querySelector(`[id="${CSS.escape(fragment)}"], [name="${CSS.escape(fragment)}"]`);
|
| 111 |
+
const setChapterToc = () =>
|
| 112 |
+
readerContext.setToc(
|
| 113 |
+
(manifest.toc ?? manifest.chapters.map((item) => ({
|
| 114 |
+
title: item.title || `章节 ${item.index}`, chapter: item.index, depth: 0, fragment: ""
|
| 115 |
+
}))).map((item) => ({
|
| 116 |
+
label: item.title,
|
| 117 |
+
chapterIndex: item.chapter,
|
| 118 |
+
fragment: item.fragment,
|
| 119 |
+
depth: item.depth,
|
| 120 |
+
activate: (generation) => activateChapter(manifest.chapters[item.chapter - 1], generation, item.fragment)
|
| 121 |
+
}))
|
| 122 |
+
);
|
| 123 |
+
const chapterScheduler = VoiceOfMLReaderChapters.createChapterScheduler({
|
| 124 |
+
create: async (index, signal, priority) => {
|
| 125 |
+
const chapter = chapterAt(index);
|
| 126 |
+
readerContext.assertReaderActive();
|
| 127 |
+
const url = chapterUrl(chapter);
|
| 128 |
+
if (!url) throw new Error("EPUB_INVALID_PATH");
|
| 129 |
+
const result = await readerContext.fetchReaderUrl(url, { signal, priority });
|
| 130 |
+
if (!result.ok) throw new Error(`HTTP ${result.status}`);
|
| 131 |
+
const bytes = await VoiceOfMLReaderSecurity.readBytes(
|
| 132 |
+
result,
|
| 133 |
+
VoiceOfMLReaderSecurity.LIMITS.chapterBytes,
|
| 134 |
+
chapterBudget
|
| 135 |
+
);
|
| 136 |
+
let inserted = false;
|
| 137 |
+
try {
|
| 138 |
+
readerContext.assertReaderActive();
|
| 139 |
+
signal.throwIfAborted();
|
| 140 |
+
const doc = readerContext.parseChapterDocument(new TextDecoder().decode(bytes));
|
| 141 |
+
for (const element of doc.querySelectorAll("*")) {
|
| 142 |
+
for (const name of ["src", "href", "poster", "xlink:href"])
|
| 143 |
+
if (element.hasAttribute(name)) {
|
| 144 |
+
const value = element.getAttribute(name) || "";
|
| 145 |
+
if (name === "href" && element.localName === "a" &&
|
| 146 |
+
readerContext.trustedChapterReferenceUrl(value, url, manifestBase)) continue;
|
| 147 |
+
const resolved = resourceUrl(value, url, name === "href" ? "href" : "resource");
|
| 148 |
+
if (resolved) element.setAttribute(name, resolved);
|
| 149 |
+
else if (!(name === "href" && value.startsWith("#"))) element.removeAttribute(name);
|
| 150 |
+
}
|
| 151 |
+
if (element.hasAttribute("srcset")) element.removeAttribute("srcset");
|
| 152 |
+
}
|
| 153 |
+
const article = document.createElement("article");
|
| 154 |
+
article.className = "reader-markdown reader-epub-chapter";
|
| 155 |
+
article.dataset.chapter = String(chapter.index);
|
| 156 |
+
if (doc.body?.id) article.id = doc.body.id;
|
| 157 |
+
article.innerHTML = doc.body ? doc.body.innerHTML : "";
|
| 158 |
+
for (const image of article.querySelectorAll("img")) {
|
| 159 |
+
image.loading = priority === "high" ? "eager" : "lazy";
|
| 160 |
+
image.fetchPriority = priority;
|
| 161 |
+
image.decoding = "async";
|
| 162 |
+
}
|
| 163 |
+
// The generated text index includes head text (e.g. the chapter title).
|
| 164 |
+
article._chapterSearchHead = doc.head ? document.importNode(doc.head, true) : null;
|
| 165 |
+
insertChapter(article);
|
| 166 |
+
loaded.add(chapter.index);
|
| 167 |
+
retainedBytes.set(chapter.index, bytes.byteLength);
|
| 168 |
+
inserted = true;
|
| 169 |
+
for (const entry of readerContext.navigationState.tocEntries)
|
| 170 |
+
if (entry.chapterIndex === chapter.index)
|
| 171 |
+
entry.target = chapterTarget(article, entry.fragment);
|
| 172 |
+
const next = chapterAt(chapter.index + 1);
|
| 173 |
+
if (next) ensureSentinel(next);
|
| 174 |
+
const previous = chapterAt(chapter.index - 1);
|
| 175 |
+
if (previous) ensureSentinel(previous, true);
|
| 176 |
+
scheduleChapterWindow();
|
| 177 |
+
} catch (error) {
|
| 178 |
+
if (!inserted) chapterBudget.release(bytes.byteLength);
|
| 179 |
+
throw error;
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
});
|
| 183 |
+
const fetchChapter = (chapter, demand = true, exclusive = false) => {
|
| 184 |
+
readerContext.assertReaderActive();
|
| 185 |
+
if (loaded.has(chapter.index)) return Promise.resolve();
|
| 186 |
+
return chapterScheduler.load(chapter.index, demand, exclusive).then(() => {
|
| 187 |
+
readerContext.foliateScrollAnchors.whenIdle(trimChapterWindow);
|
| 188 |
+
}).catch(error => {
|
| 189 |
+
if (error.name !== "AbortError" && !readerContext.readerAbortController.signal.aborted) {
|
| 190 |
+
const marker = ensureSentinel(chapter);
|
| 191 |
+
marker?.classList.add("reader-chapter-load-error");
|
| 192 |
+
marker?.removeAttribute("aria-hidden");
|
| 193 |
+
}
|
| 194 |
+
throw error;
|
| 195 |
+
});
|
| 196 |
+
};
|
| 197 |
+
const activateChapter = async (chapter, generation, fragment = "") => {
|
| 198 |
+
chapterFocusIndex = chapter.index;
|
| 199 |
+
chapterNavigating++;
|
| 200 |
+
try { await fetchChapter(chapter, true, chapterWindowReady); }
|
| 201 |
+
finally { chapterNavigating--; }
|
| 202 |
+
if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false;
|
| 203 |
+
const node = frame.querySelector(`.reader-epub-chapter[data-chapter="${chapter.index}"]`);
|
| 204 |
+
let id = fragment.replace(/^#/, "");
|
| 205 |
+
try {
|
| 206 |
+
id = decodeURIComponent(id);
|
| 207 |
+
} catch (_) {}
|
| 208 |
+
const anchor = chapterTarget(node, id);
|
| 209 |
+
if (!anchor) throw new Error("EPUB_MISSING_ANCHOR");
|
| 210 |
+
readerContext.foliateScrollAnchors.invalidate();
|
| 211 |
+
anchor.scrollIntoView({ block: "start" });
|
| 212 |
+
readerContext.foliateScrollAnchors.remember();
|
| 213 |
+
readerContext.syncHeadingLocation();
|
| 214 |
+
readerContext.updateProgressTools();
|
| 215 |
+
readerContext.scheduleSave();
|
| 216 |
+
return true;
|
| 217 |
+
};
|
| 218 |
+
frame.addEventListener("click", async (event) => {
|
| 219 |
+
const link = event.target.closest?.("a[href]");
|
| 220 |
+
const article = link?.closest(".reader-epub-chapter");
|
| 221 |
+
if (
|
| 222 |
+
!article ||
|
| 223 |
+
event.button !== 0 ||
|
| 224 |
+
event.ctrlKey ||
|
| 225 |
+
event.metaKey ||
|
| 226 |
+
event.shiftKey ||
|
| 227 |
+
event.altKey
|
| 228 |
+
)
|
| 229 |
+
return;
|
| 230 |
+
const current = chapterAt(Number(article.dataset.chapter));
|
| 231 |
+
const destination = readerContext.trustedChapterReferenceUrl(link.getAttribute("href"), chapterUrl(current), manifestBase);
|
| 232 |
+
const target = destination ? new URL(destination.url) : null;
|
| 233 |
+
const fragment = destination?.fragment || "";
|
| 234 |
+
const chapter = target && manifest.chapters.find((item) => chapterUrl(item) === target.href);
|
| 235 |
+
if (!chapter) {
|
| 236 |
+
// A stale or missing book document must not navigate out to an asset 404.
|
| 237 |
+
if (/\.(?:xhtml?|html?)(?:$|#)/i.test(link.getAttribute("href") || "")) {
|
| 238 |
+
event.preventDefault();
|
| 239 |
+
let message = link.nextElementSibling;
|
| 240 |
+
if (!message?.classList.contains("reader-chapter-link-error")) {
|
| 241 |
+
message = document.createElement("span");
|
| 242 |
+
message.className = "reader-chapter-link-error";
|
| 243 |
+
message.setAttribute("role", "status");
|
| 244 |
+
message.textContent = "(无法定位此书内链接)";
|
| 245 |
+
link.after(message);
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
return;
|
| 249 |
+
}
|
| 250 |
+
event.preventDefault();
|
| 251 |
+
const generation = readerContext.beginReaderNavigation();
|
| 252 |
+
try {
|
| 253 |
+
await activateChapter(chapter, generation, fragment);
|
| 254 |
+
} catch (error) {
|
| 255 |
+
readerContext.reportNavigationError(error, generation);
|
| 256 |
+
}
|
| 257 |
+
});
|
| 258 |
+
const chapterVirtualizer = VoiceOfMLReaderVirtual.createSectionVirtualizer({
|
| 259 |
+
limit: 12,
|
| 260 |
+
getLoaded: () => [...frame.querySelectorAll(":scope > .reader-epub-chapter")],
|
| 261 |
+
getIndex: article => Number(article.dataset.chapter),
|
| 262 |
+
getHeight: article => article.getBoundingClientRect().height,
|
| 263 |
+
canVirtualize: article => {
|
| 264 |
+
const rect = article.getBoundingClientRect(), view = readerContext.viewport.getBoundingClientRect();
|
| 265 |
+
const selection = window.getSelection();
|
| 266 |
+
return (rect.bottom < view.top - 800 || rect.top > view.bottom + 800) &&
|
| 267 |
+
!article.contains(document.activeElement) &&
|
| 268 |
+
!article.querySelector("mark.full-search-highlight") &&
|
| 269 |
+
!(selection && !selection.isCollapsed &&
|
| 270 |
+
selection.containsNode(article, true));
|
| 271 |
+
},
|
| 272 |
+
virtualize: (article, index, height) => {
|
| 273 |
+
loaded.delete(index);
|
| 274 |
+
const marker = ensureSentinel(chapterAt(index));
|
| 275 |
+
const style = getComputedStyle(article);
|
| 276 |
+
marker.classList.add("reader-chapter-placeholder");
|
| 277 |
+
marker.style.height = `${height}px`;
|
| 278 |
+
marker.style.marginTop = style.marginTop;
|
| 279 |
+
marker.style.marginBottom = style.marginBottom;
|
| 280 |
+
marker.setAttribute("aria-hidden", "true");
|
| 281 |
+
readerContext.chapterManifestObserver.unobserve(article);
|
| 282 |
+
readerContext.foliateScrollAnchors.unobserve(article);
|
| 283 |
+
for (const entry of readerContext.navigationState.tocEntries)
|
| 284 |
+
if (entry.chapterIndex === index) entry.target = null;
|
| 285 |
+
for (const image of article.querySelectorAll("img")) image.removeAttribute("src");
|
| 286 |
+
article.replaceWith(marker);
|
| 287 |
+
article._chapterSearchHead = null;
|
| 288 |
+
},
|
| 289 |
+
release: index => {
|
| 290 |
+
chapterBudget.release(retainedBytes.get(index));
|
| 291 |
+
retainedBytes.delete(index);
|
| 292 |
+
},
|
| 293 |
+
preserve: change => readerContext.foliateScrollAnchors.preserve(change)
|
| 294 |
+
});
|
| 295 |
+
function trimChapterWindow() {
|
| 296 |
+
if (chapterWindowReady) chapterVirtualizer.trim(chapterFocusIndex);
|
| 297 |
+
}
|
| 298 |
+
function chapterPrefetchIndices() {
|
| 299 |
+
if (document.hidden) return [];
|
| 300 |
+
// A fixed opening prefix keeps extra front matter within the DOM retention target.
|
| 301 |
+
const forwardCount = Math.max(chapterPrefetchCount, chapterPrefetchMaxCount + 1 - chapterFocusIndex);
|
| 302 |
+
const nearby = manifest.chapters.slice(Math.max(0, chapterFocusIndex - 2),
|
| 303 |
+
chapterFocusIndex + forwardCount);
|
| 304 |
+
const indices = [];
|
| 305 |
+
let bytes = 0;
|
| 306 |
+
for (const chapter of nearby) {
|
| 307 |
+
bytes += chapter.bytes;
|
| 308 |
+
if (chapter.index > chapterFocusIndex + chapterPrefetchCount && bytes > chapterPrefetchBytes) break;
|
| 309 |
+
if (!loaded.has(chapter.index)) indices.push(chapter.index);
|
| 310 |
+
}
|
| 311 |
+
return indices;
|
| 312 |
+
}
|
| 313 |
+
function refreshChapterWindow() {
|
| 314 |
+
chapterWindowFrame = 0;
|
| 315 |
+
if (!chapterWindowReady || readerContext.readerAbortController.signal.aborted) return;
|
| 316 |
+
const view = readerContext.viewport.getBoundingClientRect();
|
| 317 |
+
const rows = [...frame.querySelectorAll(":scope > [data-chapter]")];
|
| 318 |
+
const current = rows.find(row => {
|
| 319 |
+
const rect = row.getBoundingClientRect();
|
| 320 |
+
return rect.top <= view.top + 80 && rect.bottom > view.top + 80;
|
| 321 |
+
}) || rows.find(row => row.getBoundingClientRect().top > view.top);
|
| 322 |
+
if (current && !chapterNavigating) chapterFocusIndex = Number(current.dataset.chapter);
|
| 323 |
+
if (!chapterNavigating && current?.classList.contains("reader-chapter-sentinel") &&
|
| 324 |
+
current.getBoundingClientRect().top < view.bottom)
|
| 325 |
+
fetchChapter(chapterAt(chapterFocusIndex)).catch(() => {});
|
| 326 |
+
if (!chapterNavigating) chapterScheduler.prefetch(chapterPrefetchIndices());
|
| 327 |
+
readerContext.foliateScrollAnchors.whenIdle(trimChapterWindow);
|
| 328 |
+
}
|
| 329 |
+
function scheduleChapterWindow() {
|
| 330 |
+
if (!chapterWindowFrame) chapterWindowFrame = requestAnimationFrame(refreshChapterWindow);
|
| 331 |
+
}
|
| 332 |
+
function handleChapterVisibility() {
|
| 333 |
+
if (document.hidden) chapterScheduler.prefetch([]);
|
| 334 |
+
else scheduleChapterWindow();
|
| 335 |
+
}
|
| 336 |
+
readerContext.chapterManifestObserver = new IntersectionObserver(
|
| 337 |
+
scheduleChapterWindow,
|
| 338 |
+
{ root: readerContext.viewport, rootMargin: "800px 0px" }
|
| 339 |
+
);
|
| 340 |
+
chapterPreviousObserver = new IntersectionObserver((entries) => {
|
| 341 |
+
if (!chapterWindowReady || chapterNavigating || readerContext.readerAbortController.signal.aborted) return;
|
| 342 |
+
for (const entry of entries) {
|
| 343 |
+
if (!entry.isIntersecting) continue;
|
| 344 |
+
const chapter = chapterAt(Number(entry.target.dataset.chapter));
|
| 345 |
+
if (chapter) fetchChapter(chapter).catch(() => {});
|
| 346 |
+
}
|
| 347 |
+
}, { root: readerContext.viewport });
|
| 348 |
+
readerContext.viewport.addEventListener("scroll", scheduleChapterWindow, { passive: true });
|
| 349 |
+
window.addEventListener("resize", scheduleChapterWindow);
|
| 350 |
+
document.addEventListener("visibilitychange", handleChapterVisibility);
|
| 351 |
+
let chapterCleaned = false;
|
| 352 |
+
readerContext.chapterManifestCleanup = () => {
|
| 353 |
+
if (chapterCleaned) return;
|
| 354 |
+
chapterCleaned = true;
|
| 355 |
+
chapterWindowReady = false;
|
| 356 |
+
cancelAnimationFrame(chapterWindowFrame);
|
| 357 |
+
readerContext.viewport.removeEventListener("scroll", scheduleChapterWindow);
|
| 358 |
+
window.removeEventListener("resize", scheduleChapterWindow);
|
| 359 |
+
document.removeEventListener("visibilitychange", handleChapterVisibility);
|
| 360 |
+
chapterScheduler.dispose();
|
| 361 |
+
chapterVirtualizer.dispose();
|
| 362 |
+
readerContext.chapterManifestObserver?.disconnect();
|
| 363 |
+
chapterPreviousObserver?.disconnect();
|
| 364 |
+
readerContext.chapterManifestObserver = null;
|
| 365 |
+
readerContext.chapterManifestLoader = null;
|
| 366 |
+
for (const bytes of retainedBytes.values()) chapterBudget.release(bytes);
|
| 367 |
+
retainedBytes.clear();
|
| 368 |
+
loaded.clear();
|
| 369 |
+
};
|
| 370 |
+
readerContext.trackReaderResource(readerContext.chapterManifestCleanup);
|
| 371 |
+
readerContext.chapterManifestLoader = async (index) => {
|
| 372 |
+
const chapter = chapterAt(index);
|
| 373 |
+
if (!chapter) return null;
|
| 374 |
+
chapterFocusIndex = index;
|
| 375 |
+
chapterNavigating++;
|
| 376 |
+
try { await fetchChapter(chapter, true, chapterWindowReady); }
|
| 377 |
+
finally { chapterNavigating--; }
|
| 378 |
+
return frame.querySelector(`.reader-epub-chapter[data-chapter="${index}"]`);
|
| 379 |
+
};
|
| 380 |
+
readerContext.chapterManifestLoader.count = manifest.chapters.length;
|
| 381 |
+
setChapterToc();
|
| 382 |
+
await readerContext.getInitialReaderRestoration();
|
| 383 |
+
const savedChapter = Number(readerContext.readerRestorationEntry?.chapterIndex) - 1;
|
| 384 |
+
const initialChapter = Number.isInteger(savedChapter)
|
| 385 |
+
? Math.max(0, Math.min(manifest.chapters.length - 1, savedChapter))
|
| 386 |
+
: 0;
|
| 387 |
+
chapterFocusIndex = manifest.chapters[initialChapter].index;
|
| 388 |
+
if (initialChapter > 0) ensureSentinel(manifest.chapters[initialChapter - 1], true);
|
| 389 |
+
try {
|
| 390 |
+
const firstChapter = fetchChapter(manifest.chapters[initialChapter]);
|
| 391 |
+
chapterScheduler.prefetch(chapterPrefetchIndices());
|
| 392 |
+
await firstChapter;
|
| 393 |
+
} catch (error) {
|
| 394 |
+
if (readerContext.readerRestorationEntry?.chapterIndex == null) throw error;
|
| 395 |
+
throw Object.assign(new Error("阅读位置恢复失败,原进度已保留,请重试加载。", { cause: error }), {
|
| 396 |
+
code: "READER_RESTORE"
|
| 397 |
+
});
|
| 398 |
+
}
|
| 399 |
+
chapterWindowReady = true;
|
| 400 |
+
if (readerContext.isReaderGenerationCurrent("navigation", openingGeneration)) readerContext.foliateScrollAnchors.invalidate();
|
| 401 |
+
for (const article of frame.querySelectorAll(".reader-epub-chapter")) readerContext.foliateScrollAnchors.observe(article);
|
| 402 |
+
frame.style.overflowAnchor = "none";
|
| 403 |
+
scheduleChapterWindow();
|
| 404 |
+
readerContext.assertReaderActive();
|
| 405 |
+
readerContext.markReaderContentReady();
|
| 406 |
+
readerContext.status.textContent = `EPUB · ${manifest.chapters.length} 章`;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
function captureChapterPosition() {
|
| 410 |
+
if (readerContext.capability.mode !== "epub-chapters") return null;
|
| 411 |
+
const marker = readerContext.viewport.getBoundingClientRect().top + 8;
|
| 412 |
+
const chapters = [...readerContext.content.querySelectorAll(".reader-epub-chapter")];
|
| 413 |
+
const chapter =
|
| 414 |
+
chapters.find((node) => node.getBoundingClientRect().bottom > marker) ||
|
| 415 |
+
chapters[chapters.length - 1];
|
| 416 |
+
return chapter
|
| 417 |
+
? {
|
| 418 |
+
chapterIndex: Number(chapter.dataset.chapter),
|
| 419 |
+
chapterManifest: readerContext.chapterManifestUrl || readerContext.sourceUrl,
|
| 420 |
+
chapterOffset: marker - chapter.getBoundingClientRect().top
|
| 421 |
+
}
|
| 422 |
+
: null;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
async function restoreChapterPosition(entry, generation) {
|
| 426 |
+
if (
|
| 427 |
+
/-epub-chapters-v5\//.test(readerContext.chapterManifestUrl || readerContext.sourceUrl) &&
|
| 428 |
+
entry.chapterManifest !== (readerContext.chapterManifestUrl || readerContext.sourceUrl)
|
| 429 |
+
) return false;
|
| 430 |
+
if (!readerContext.chapterManifestLoader || !Number.isInteger(entry.chapterIndex)) return false;
|
| 431 |
+
const chapter = await readerContext.chapterManifestLoader(entry.chapterIndex);
|
| 432 |
+
if (!chapter?.isConnected || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false;
|
| 433 |
+
readerContext.viewport.scrollTop +=
|
| 434 |
+
chapter.getBoundingClientRect().top -
|
| 435 |
+
readerContext.viewport.getBoundingClientRect().top -
|
| 436 |
+
8 +
|
| 437 |
+
(Number(entry.chapterOffset) || 0);
|
| 438 |
+
readerContext.foliateScrollAnchors.remember();
|
| 439 |
+
readerContext.syncHeadingLocation();
|
| 440 |
+
readerContext.updateProgressTools();
|
| 441 |
+
readerContext.scheduleSave();
|
| 442 |
+
return true;
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
return {
|
| 446 |
+
renderChapterManifest,
|
| 447 |
+
captureChapterPosition,
|
| 448 |
+
restoreChapterPosition
|
| 449 |
+
};
|
| 450 |
+
}
|
static/{reader-chapter-repository.js → reader/formats/reader-chapter-repository.js}
RENAMED
|
File without changes
|
static/reader/formats/reader-docx-format.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function createReaderDocxFormat(readerContext) {
|
| 2 |
+
async function renderDocx(prepared) {
|
| 3 |
+
const bytes = await readerContext.readPreparedBytes(prepared.response, prepared.engines);
|
| 4 |
+
readerContext.assertReaderActive();
|
| 5 |
+
VoiceOfMLReaderSecurity.inspectZip(bytes, {
|
| 6 |
+
...VoiceOfMLReaderSecurity.LIMITS,
|
| 7 |
+
archiveCompressedBytes: VoiceOfMLReaderSecurity.LIMITS.documentBytes
|
| 8 |
+
});
|
| 9 |
+
const styles = document.createElement("div");
|
| 10 |
+
styles.className = "docx-styles";
|
| 11 |
+
const body = document.createElement("div");
|
| 12 |
+
body.className = "docx-body";
|
| 13 |
+
readerContext.content.append(styles, body);
|
| 14 |
+
const options = {
|
| 15 |
+
className: "reader-docx",
|
| 16 |
+
inWrapper: true,
|
| 17 |
+
breakPages: true,
|
| 18 |
+
ignoreLastRenderedPageBreak: false,
|
| 19 |
+
useBase64URL: false,
|
| 20 |
+
renderHeaders: true,
|
| 21 |
+
renderFooters: true,
|
| 22 |
+
renderFootnotes: true,
|
| 23 |
+
renderEndnotes: true,
|
| 24 |
+
renderChanges: false,
|
| 25 |
+
renderComments: false,
|
| 26 |
+
renderAltChunks: false,
|
| 27 |
+
debug: false
|
| 28 |
+
};
|
| 29 |
+
const docxDocument = await readerContext.awaitReader(docx.parseAsync(bytes, options));
|
| 30 |
+
readerContext.assertReaderActive();
|
| 31 |
+
const urls = new Set(), urlsByPath = new Map(), contentTypes = docxDocument.contentTypes || [];
|
| 32 |
+
let released = false;
|
| 33 |
+
const release = () => {
|
| 34 |
+
released = true;
|
| 35 |
+
for (const url of urls) URL.revokeObjectURL(url);
|
| 36 |
+
urls.clear();
|
| 37 |
+
urlsByPath.clear();
|
| 38 |
+
};
|
| 39 |
+
const untrack = readerContext.trackReaderResource(release);
|
| 40 |
+
readerContext.memoizeDocxAssets(docxDocument, () => released);
|
| 41 |
+
// Own this parsed document's images, fonts and numbering assets only.
|
| 42 |
+
docxDocument.blobToURL = (blob, path) => {
|
| 43 |
+
if (released || !blob) return null;
|
| 44 |
+
if (path && urlsByPath.has(path)) return urlsByPath.get(path);
|
| 45 |
+
const type = path && contentTypes.find(entry => entry.partName === path ||
|
| 46 |
+
(entry.extension && path.endsWith(`.${entry.extension}`)));
|
| 47 |
+
const url = URL.createObjectURL(type ? new Blob([blob], {type:type.contentType}) : blob);
|
| 48 |
+
if (typeof url === "string" && url.startsWith("blob:")) {
|
| 49 |
+
urls.add(url);
|
| 50 |
+
if (path) urlsByPath.set(path, url);
|
| 51 |
+
}
|
| 52 |
+
return url;
|
| 53 |
+
};
|
| 54 |
+
try {
|
| 55 |
+
const nodes = await readerContext.awaitReader(docx.renderDocument(docxDocument, options));
|
| 56 |
+
readerContext.assertReaderActive();
|
| 57 |
+
for (const node of nodes) (node.nodeName === "STYLE" ? styles : body).appendChild(node);
|
| 58 |
+
} catch (error) {
|
| 59 |
+
release();
|
| 60 |
+
untrack();
|
| 61 |
+
throw error;
|
| 62 |
+
}
|
| 63 |
+
readerContext.assertReaderActive();
|
| 64 |
+
body.classList.toggle("reader-document-dark", readerContext.readerTheme === "dark");
|
| 65 |
+
if (!(body.textContent || "").trim() && !body.querySelector("img, table, svg, canvas")) {
|
| 66 |
+
release();
|
| 67 |
+
untrack();
|
| 68 |
+
throw new Error("DOCX rendered no supported content");
|
| 69 |
+
}
|
| 70 |
+
const pages = [...body.querySelectorAll(":scope > .reader-docx-wrapper > section.reader-docx")];
|
| 71 |
+
readerContext.docxPageNodes = pages;
|
| 72 |
+
if (pages.length) {
|
| 73 |
+
readerContext.updateDocumentState({ pageCount: pages.length });
|
| 74 |
+
readerContext.pageInput.max = String(readerContext.documentState.pageCount);
|
| 75 |
+
document.querySelector("#page-total").textContent = `/ ${readerContext.documentState.pageCount}`;
|
| 76 |
+
document.querySelector(".page-controls").hidden = false;
|
| 77 |
+
pages.forEach((page, index) => {
|
| 78 |
+
page.classList.add("reader-docx-page");
|
| 79 |
+
page.dataset.page = String(index + 1);
|
| 80 |
+
page.style.setProperty("--reader-docx-height", page.style.minHeight || page.style.height || "1123px");
|
| 81 |
+
});
|
| 82 |
+
readerContext.setToc(readerContext.headingTocEntries(body));
|
| 83 |
+
}
|
| 84 |
+
for (const link of body.querySelectorAll("a[href]")) {
|
| 85 |
+
const href = link.getAttribute("href") || "";
|
| 86 |
+
if (!href.startsWith("#") && !/^https?:\/\//i.test(href)) link.removeAttribute("href");
|
| 87 |
+
else if (!href.startsWith("#")) {
|
| 88 |
+
link.target = "_blank";
|
| 89 |
+
link.rel = "noopener noreferrer";
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
readerContext.markReaderContentReady();
|
| 93 |
+
readerContext.status.textContent = readerContext.documentState.pageCount ? `${readerContext.documentState.pageCount} 页` : "DOCX";
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function memoizeDocxResource(load, keyFor, isReleased) {
|
| 97 |
+
const pending = new Map();
|
| 98 |
+
return (...args) => {
|
| 99 |
+
if (isReleased()) return Promise.resolve(null);
|
| 100 |
+
const key = keyFor(...args);
|
| 101 |
+
if (!key) return load(...args);
|
| 102 |
+
if (!pending.has(key)) {
|
| 103 |
+
const task = Promise.resolve().then(() => isReleased() ? null : load(...args));
|
| 104 |
+
pending.set(key, task);
|
| 105 |
+
task.catch(() => { if (pending.get(key) === task) pending.delete(key); });
|
| 106 |
+
}
|
| 107 |
+
return pending.get(key);
|
| 108 |
+
};
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function memoizeDocxAssets(doc, isReleased) {
|
| 112 |
+
if (typeof doc.getPathById !== "function") return;
|
| 113 |
+
for (const [name, part] of [["loadDocumentImage", "documentPart"],
|
| 114 |
+
["loadNumberingImage", "numberingPart"], ["loadFont", "fontTablePart"]]) {
|
| 115 |
+
if (typeof doc[name] !== "function") continue;
|
| 116 |
+
const original = doc[name].bind(doc);
|
| 117 |
+
doc[name] = readerContext.memoizeDocxResource(original, (id, parameter) => {
|
| 118 |
+
const path = doc.getPathById(name === "loadDocumentImage" ? parameter || doc[part] : doc[part], id);
|
| 119 |
+
return path ? (name === "loadFont" ? `${path}\u0000${parameter}` : path) : null;
|
| 120 |
+
}, isReleased);
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
return {
|
| 125 |
+
renderDocx,
|
| 126 |
+
memoizeDocxResource,
|
| 127 |
+
memoizeDocxAssets
|
| 128 |
+
};
|
| 129 |
+
}
|
static/reader/formats/reader-foliate-format.mjs
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function createReaderFoliateFormat(readerContext) {
|
| 2 |
+
function foliateSectionRoot(article) {
|
| 3 |
+
return article?.shadowRoot || article;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
function foliateSectionQuery(article, selector) {
|
| 7 |
+
return readerContext.foliateSectionRoot(article)?.querySelector(selector) || null;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
function foliateSectionCandidates() {
|
| 11 |
+
return [
|
| 12 |
+
...readerContext.content.querySelectorAll(
|
| 13 |
+
".foliate-continuous > article[data-section]:not(.foliate-section-placeholder), .reader-epub-chapter"
|
| 14 |
+
)
|
| 15 |
+
].flatMap((article) => [
|
| 16 |
+
article,
|
| 17 |
+
...readerContext.foliateSectionRoot(article).querySelectorAll(
|
| 18 |
+
":is(h1,h2,h3,h4,h5,h6,p,li,blockquote,pre,img,table)"
|
| 19 |
+
)
|
| 20 |
+
]);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function syncFoliateScrollLocation() {
|
| 24 |
+
if (!readerContext.foliateContinuous || !readerContext.navigationState.tocEntries.length) return;
|
| 25 |
+
const rows = [...document.querySelectorAll(".foliate-continuous article[data-section]")],
|
| 26 |
+
top = readerContext.viewport.getBoundingClientRect().top + 80;
|
| 27 |
+
let sectionIndex = -1,
|
| 28 |
+
sectionNode = null;
|
| 29 |
+
for (const row of rows)
|
| 30 |
+
if (row.getBoundingClientRect().top <= top) {
|
| 31 |
+
sectionIndex = Number(row.dataset.section);
|
| 32 |
+
sectionNode = row;
|
| 33 |
+
}
|
| 34 |
+
if (!sectionNode) return;
|
| 35 |
+
let best = -1;
|
| 36 |
+
let bestPosition = -Infinity;
|
| 37 |
+
for (const [index, entry] of readerContext.navigationState.tocEntries.entries()) {
|
| 38 |
+
if (entry.sectionIndex !== sectionIndex) continue;
|
| 39 |
+
let position = sectionNode.getBoundingClientRect().top;
|
| 40 |
+
if (entry.anchor) {
|
| 41 |
+
const root = readerContext.foliateSectionRoot(sectionNode);
|
| 42 |
+
const anchor = entry.anchor({
|
| 43 |
+
getElementById: (id) => root.querySelector(`[id="${CSS.escape(id)}"]`),
|
| 44 |
+
querySelector: (selector) => root.querySelector(selector)
|
| 45 |
+
});
|
| 46 |
+
if (anchor) position = anchor.getBoundingClientRect().top;
|
| 47 |
+
}
|
| 48 |
+
if (entry.fragment) {
|
| 49 |
+
const anchor = readerContext.foliateSectionQuery(
|
| 50 |
+
sectionNode,
|
| 51 |
+
`[id="${CSS.escape(entry.fragment)}"], [name="${CSS.escape(entry.fragment)}"]`
|
| 52 |
+
);
|
| 53 |
+
if (anchor) position = anchor.getBoundingClientRect().top;
|
| 54 |
+
}
|
| 55 |
+
if (position <= top && (position > bestPosition || (position === bestPosition && index > best))) {
|
| 56 |
+
best = index;
|
| 57 |
+
bestPosition = position;
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
if (best < 0) {
|
| 61 |
+
let firstPosition = Infinity;
|
| 62 |
+
for (const [index, entry] of readerContext.navigationState.tocEntries.entries()) {
|
| 63 |
+
if (entry.sectionIndex !== sectionIndex) continue;
|
| 64 |
+
const position = entry.target?.getBoundingClientRect?.().top;
|
| 65 |
+
if (Number.isFinite(position) && position < firstPosition) {
|
| 66 |
+
best = index;
|
| 67 |
+
firstPosition = position;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
if (best >= 0 && best !== readerContext.navigationState.currentChapterIndex) {
|
| 72 |
+
readerContext.updateNavigationState({ currentChapterIndex: best });
|
| 73 |
+
readerContext.updateTocCurrentMark();
|
| 74 |
+
readerContext.updateProgressTools();
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function refreshFoliateWindow() {
|
| 79 |
+
readerContext.foliateWindowFrame = 0;
|
| 80 |
+
readerContext.foliateSectionLoader?.refresh?.();
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function scheduleFoliateWindowRefresh() {
|
| 84 |
+
if (!readerContext.foliateWindowFrame) readerContext.foliateWindowFrame = requestAnimationFrame(readerContext.refreshFoliateWindow);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function trimFoliateSections() {
|
| 88 |
+
if (!readerContext.foliateContinuous || !readerContext.foliateSectionVirtualizer) return;
|
| 89 |
+
const marker = readerContext.viewport.getBoundingClientRect().top + 80,
|
| 90 |
+
rows = [...document.querySelectorAll(".foliate-continuous article[data-section]")],
|
| 91 |
+
current =
|
| 92 |
+
rows.find((row) => {
|
| 93 |
+
const rect = row.getBoundingClientRect();
|
| 94 |
+
return rect.top <= marker && rect.bottom > marker;
|
| 95 |
+
}) || rows.find((row) => row.getBoundingClientRect().top > marker);
|
| 96 |
+
if (current) readerContext.foliateSectionVirtualizer.trim(Number(current.dataset.section));
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function scheduleFoliateScrollSync() {
|
| 100 |
+
if (readerContext.foliateScrollFrame) return;
|
| 101 |
+
readerContext.foliateScrollFrame = requestAnimationFrame(() => {
|
| 102 |
+
readerContext.foliateScrollFrame = 0;
|
| 103 |
+
readerContext.syncFoliateScrollLocation();
|
| 104 |
+
readerContext.foliateScrollAnchors.whenIdle(readerContext.trimFoliateSections);
|
| 105 |
+
});
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
async function activateFoliateTocEntry(entry, generation = readerContext.beginReaderNavigation()) {
|
| 109 |
+
const book = readerContext.epubRendition?.book,
|
| 110 |
+
href = String(entry.href || ""),
|
| 111 |
+
target = await readerContext.epubRendition?.book?.resolveHref(href);
|
| 112 |
+
if (!book || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false;
|
| 113 |
+
const section = target && book.sections[target.index],
|
| 114 |
+
sections = book.sections.filter((item) => item.linear !== "no"),
|
| 115 |
+
visibleIndex = section ? sections.indexOf(section) : -1;
|
| 116 |
+
if (!section || visibleIndex < 0 || !readerContext.foliateSectionLoader) return false;
|
| 117 |
+
let node = await readerContext.foliateSectionLoader(visibleIndex);
|
| 118 |
+
if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false;
|
| 119 |
+
// Background neighbors are not part of a demanded TOC destination.
|
| 120 |
+
await readerContext.waitForReader(0, true);
|
| 121 |
+
if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false;
|
| 122 |
+
if (!node?.isConnected) node = await readerContext.foliateSectionLoader(visibleIndex);
|
| 123 |
+
if (!node?.isConnected || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false;
|
| 124 |
+
const root = readerContext.foliateSectionRoot(node),
|
| 125 |
+
anchorDocument = {
|
| 126 |
+
getElementById: (id) => root.querySelector(`[id="${CSS.escape(id)}"]`),
|
| 127 |
+
querySelector: (selector) => root.querySelector(selector)
|
| 128 |
+
};
|
| 129 |
+
let anchor = null;
|
| 130 |
+
try {
|
| 131 |
+
anchor = typeof target.anchor === "function" ? target.anchor(anchorDocument) : null;
|
| 132 |
+
} catch (_) {}
|
| 133 |
+
if (!anchor) {
|
| 134 |
+
const fragment = href.split("#")[1] || "",
|
| 135 |
+
id = fragment ? decodeURIComponent(fragment) : "";
|
| 136 |
+
anchor = id ? root.querySelector(`[id="${CSS.escape(id)}"], [name="${CSS.escape(id)}"]`) : null;
|
| 137 |
+
}
|
| 138 |
+
const destination = anchor || node;
|
| 139 |
+
readerContext.foliateScrollAnchors.invalidate();
|
| 140 |
+
readerContext.viewport.scrollTop = Math.max(
|
| 141 |
+
0,
|
| 142 |
+
readerContext.viewport.scrollTop +
|
| 143 |
+
destination.getBoundingClientRect().top -
|
| 144 |
+
readerContext.viewport.getBoundingClientRect().top -
|
| 145 |
+
8
|
| 146 |
+
);
|
| 147 |
+
readerContext.syncFoliateScrollLocation();
|
| 148 |
+
readerContext.updateTocCurrentMark();
|
| 149 |
+
readerContext.updateProgressTools();
|
| 150 |
+
readerContext.scheduleSave();
|
| 151 |
+
readerContext.setReaderPanelOpen(false, true);
|
| 152 |
+
readerContext.foliateScrollAnchors.remember();
|
| 153 |
+
return true;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
function cloneFoliateDocument(source) {
|
| 157 |
+
const doc = source.cloneNode(true),
|
| 158 |
+
original = source.createTreeWalker(source, NodeFilter.SHOW_TEXT),
|
| 159 |
+
cloned = doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT);
|
| 160 |
+
const sourceNodes = new WeakMap();
|
| 161 |
+
while (original.nextNode() && cloned.nextNode())
|
| 162 |
+
sourceNodes.set(cloned.currentNode, original.currentNode);
|
| 163 |
+
return { doc: readerContext.sanitizeEpubDocument(doc), sourceNodes };
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function foliateSearchAnchor(source, sourceNodes, body, renderedBody) {
|
| 167 |
+
const sourceWalker = body.ownerDocument.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
| 168 |
+
const pairs = [];
|
| 169 |
+
let offset = 0;
|
| 170 |
+
while (sourceWalker.nextNode()) {
|
| 171 |
+
const node = sourceWalker.currentNode;
|
| 172 |
+
const original = sourceNodes.get(node);
|
| 173 |
+
if (original) pairs.push({ original, offset });
|
| 174 |
+
offset += node.length;
|
| 175 |
+
}
|
| 176 |
+
return (anchor) => {
|
| 177 |
+
const range = anchor(source);
|
| 178 |
+
if (!range || typeof range.intersectsNode !== "function") return null;
|
| 179 |
+
const intervals = [];
|
| 180 |
+
for (const { original, offset } of pairs) {
|
| 181 |
+
if (!range.intersectsNode(original)) continue;
|
| 182 |
+
const start = range.startContainer === original ? range.startOffset : 0;
|
| 183 |
+
const end = range.endContainer === original ? range.endOffset : original.length;
|
| 184 |
+
if (end > start) intervals.push({ start: offset + start, end: offset + end });
|
| 185 |
+
}
|
| 186 |
+
const walker = document.createTreeWalker(renderedBody, NodeFilter.SHOW_TEXT),
|
| 187 |
+
selected = [];
|
| 188 |
+
let offset = 0;
|
| 189 |
+
while (walker.nextNode()) {
|
| 190 |
+
const node = walker.currentNode,
|
| 191 |
+
end = offset + node.length;
|
| 192 |
+
for (const interval of intervals)
|
| 193 |
+
if (interval.start < end && interval.end > offset)
|
| 194 |
+
selected.push({
|
| 195 |
+
node,
|
| 196 |
+
start: Math.max(0, interval.start - offset),
|
| 197 |
+
end: Math.min(node.length, interval.end - offset)
|
| 198 |
+
});
|
| 199 |
+
offset = end;
|
| 200 |
+
}
|
| 201 |
+
return selected;
|
| 202 |
+
};
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
async function settleReaderImages(root, timeoutMs = 2500) {
|
| 206 |
+
readerContext.assertReaderActive();
|
| 207 |
+
const images = [...root.querySelectorAll("img[src],img[srcset]")];
|
| 208 |
+
if (!images.length) return;
|
| 209 |
+
let timeout = 0, active = true;
|
| 210 |
+
const cleanups = new Set();
|
| 211 |
+
const decoded = Promise.allSettled(
|
| 212 |
+
images.map(async (image) => {
|
| 213 |
+
// Sections are prepared off-DOM; lazy images cannot enter a viewport yet.
|
| 214 |
+
image.loading = "eager";
|
| 215 |
+
image.decoding = "async";
|
| 216 |
+
if (!image.complete) await new Promise((resolve) => {
|
| 217 |
+
const finish = () => {
|
| 218 |
+
image.removeEventListener("load", finish);
|
| 219 |
+
image.removeEventListener("error", finish);
|
| 220 |
+
cleanups.delete(finish);
|
| 221 |
+
resolve();
|
| 222 |
+
};
|
| 223 |
+
cleanups.add(finish);
|
| 224 |
+
image.addEventListener("load", finish, { once: true });
|
| 225 |
+
image.addEventListener("error", finish, { once: true });
|
| 226 |
+
if (image.complete) finish();
|
| 227 |
+
});
|
| 228 |
+
if (active) {
|
| 229 |
+
try { await image.decode?.(); } catch (_) {}
|
| 230 |
+
}
|
| 231 |
+
})
|
| 232 |
+
);
|
| 233 |
+
try {
|
| 234 |
+
await readerContext.awaitReader(
|
| 235 |
+
Promise.race([
|
| 236 |
+
decoded,
|
| 237 |
+
new Promise((resolve) => {
|
| 238 |
+
timeout = setTimeout(resolve, timeoutMs);
|
| 239 |
+
})
|
| 240 |
+
])
|
| 241 |
+
);
|
| 242 |
+
} finally {
|
| 243 |
+
active = false;
|
| 244 |
+
clearTimeout(timeout);
|
| 245 |
+
for (const cleanup of cleanups) cleanup();
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
function rewriteFoliateResources(doc, section) {
|
| 250 |
+
const safeEmbeddedValue = (value) =>
|
| 251 |
+
/^(?:blob:|data:image\/(?:gif|png|jpe?g|webp);)/i.test(String(value || "").trim());
|
| 252 |
+
const archiveResourceUrl = (value) => {
|
| 253 |
+
const raw = String(value || "").trim();
|
| 254 |
+
if (!raw || raw.startsWith("#") || raw.includes("#") || safeEmbeddedValue(raw))
|
| 255 |
+
return safeEmbeddedValue(raw) ? raw : null;
|
| 256 |
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(raw)) return null;
|
| 257 |
+
try {
|
| 258 |
+
const resolved = String(
|
| 259 |
+
(section.resolveResourceHref
|
| 260 |
+
? section.resolveResourceHref(raw)
|
| 261 |
+
: section.resolveHref?.(raw)) || ""
|
| 262 |
+
);
|
| 263 |
+
if (
|
| 264 |
+
!resolved ||
|
| 265 |
+
/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(resolved) ||
|
| 266 |
+
/[\\\x00]/.test(resolved) ||
|
| 267 |
+
resolved.split("/").some((part) => !part || part === "." || part === "..")
|
| 268 |
+
)
|
| 269 |
+
return null;
|
| 270 |
+
return `/api/reader-resource?book=${encodeURIComponent(readerContext.sourceUrl)}&path=${encodeURIComponent(resolved)}`;
|
| 271 |
+
} catch (_) {
|
| 272 |
+
return null;
|
| 273 |
+
}
|
| 274 |
+
};
|
| 275 |
+
for (const element of doc.querySelectorAll(
|
| 276 |
+
"img[src],source[src],audio[src],video[src],video[poster]"
|
| 277 |
+
)) {
|
| 278 |
+
for (const name of ["src", "poster"])
|
| 279 |
+
if (element.hasAttribute(name)) {
|
| 280 |
+
const safe = archiveResourceUrl(element.getAttribute(name));
|
| 281 |
+
if (safe) element.setAttribute(name, safe);
|
| 282 |
+
else element.removeAttribute(name);
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
for (const element of doc.querySelectorAll("[srcset]")) {
|
| 286 |
+
const value = element.getAttribute("srcset") || "";
|
| 287 |
+
const candidates = value
|
| 288 |
+
.split(",")
|
| 289 |
+
.map((part) => {
|
| 290 |
+
const [raw, ...descriptor] = part.trim().split(/\s+/);
|
| 291 |
+
const safe = archiveResourceUrl(raw);
|
| 292 |
+
return safe ? [safe, ...descriptor].join(" ") : null;
|
| 293 |
+
})
|
| 294 |
+
.filter(Boolean);
|
| 295 |
+
if (candidates.length) element.setAttribute("srcset", candidates.join(", "));
|
| 296 |
+
else element.removeAttribute("srcset");
|
| 297 |
+
}
|
| 298 |
+
for (const image of doc.querySelectorAll("image")) {
|
| 299 |
+
const value =
|
| 300 |
+
image.getAttributeNS("http://www.w3.org/1999/xlink", "href") ||
|
| 301 |
+
image.getAttribute("xlink:href") ||
|
| 302 |
+
image.getAttribute("href");
|
| 303 |
+
const safe = archiveResourceUrl(value);
|
| 304 |
+
if (safe) {
|
| 305 |
+
image.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", safe);
|
| 306 |
+
image.setAttribute("href", safe);
|
| 307 |
+
} else {
|
| 308 |
+
image.removeAttributeNS("http://www.w3.org/1999/xlink", "href");
|
| 309 |
+
image.removeAttribute("xlink:href");
|
| 310 |
+
image.removeAttribute("href");
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
for (const link of doc.querySelectorAll("a[href]")) {
|
| 314 |
+
const href = link.getAttribute("href") || "";
|
| 315 |
+
if (/\.(?:gif|png|jpe?g|webp)$/i.test(href)) {
|
| 316 |
+
const resource = archiveResourceUrl(href);
|
| 317 |
+
if (resource) {
|
| 318 |
+
link.setAttribute("href", resource);
|
| 319 |
+
link.target = "_blank";
|
| 320 |
+
link.rel = "noopener noreferrer";
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
if (/^(?:https?:|mailto:|tel:)/i.test(href)) {
|
| 324 |
+
link.target = "_blank";
|
| 325 |
+
link.rel = "noopener noreferrer";
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
async function createFoliateSection(section, index) {
|
| 331 |
+
const signal = arguments[2] || readerContext.readerAbortController.signal;
|
| 332 |
+
const priority = arguments[3] || "high";
|
| 333 |
+
const source = await readerContext.awaitReader(section.createDocument(), signal);
|
| 334 |
+
const { doc, sourceNodes } = readerContext.cloneFoliateDocument(source);
|
| 335 |
+
readerContext.assertReaderActive();
|
| 336 |
+
readerContext.rewriteFoliateResources(doc, section);
|
| 337 |
+
const styles = [...doc.querySelectorAll("style,link")].map((node) => node.cloneNode(true));
|
| 338 |
+
for (const node of doc.querySelectorAll("style,link")) node.remove();
|
| 339 |
+
const article = document.createElement("article");
|
| 340 |
+
article.dataset.section = String(index);
|
| 341 |
+
const shadow = article.attachShadow({ mode: "open" }),
|
| 342 |
+
baseStyle = document.createElement("style"),
|
| 343 |
+
sectionBody = document.createElement("div");
|
| 344 |
+
// Chapter styles live in a shadow root, so document-level preferences cannot reach them.
|
| 345 |
+
baseStyle.textContent = `
|
| 346 |
+
:host { display: block; color: inherit; }
|
| 347 |
+
* { box-sizing: border-box; }
|
| 348 |
+
.reader-section-body { display: block; color: inherit; line-height: inherit; }
|
| 349 |
+
.reader-section-body > :first-child { margin-top: 0 !important; }
|
| 350 |
+
:host(.reader-document-dark) .reader-section-body,
|
| 351 |
+
:host(.reader-document-dark) .reader-section-body :where(:not(svg, svg *, img, canvas, video, audio, math, math *)) {
|
| 352 |
+
color: inherit !important;
|
| 353 |
+
background-color: transparent !important;
|
| 354 |
+
border-color: #4a5056 !important;
|
| 355 |
+
text-shadow: none !important;
|
| 356 |
+
}
|
| 357 |
+
a { color: var(--reader-book-link) !important; }
|
| 358 |
+
:host(.reader-document-dark) .reader-section-body a { color: var(--reader-book-link) !important; }
|
| 359 |
+
img, svg, video { max-width: 100%; height: auto; }
|
| 360 |
+
mark.full-search-highlight { background: #ffd54f; color: #111; }
|
| 361 |
+
:host(.reader-document-dark) .reader-section-body mark.full-search-highlight { background: #ffd54f !important; color: #111 !important; }
|
| 362 |
+
@media (prefers-contrast: more) {
|
| 363 |
+
.reader-section-body a[href] { text-decoration: underline !important; }
|
| 364 |
+
.reader-section-body mark.full-search-highlight { outline: 2px solid currentColor; }
|
| 365 |
+
}
|
| 366 |
+
`;
|
| 367 |
+
sectionBody.className = "reader-section-body";
|
| 368 |
+
const body = readerContext.epubContentBody(doc);
|
| 369 |
+
// Import the sanitized nodes directly. Serializing and reparsing legacy
|
| 370 |
+
// nested anchors lets HTML's adoption rules reorder footnotes and citations.
|
| 371 |
+
if (body)
|
| 372 |
+
sectionBody.append(...[...body.childNodes].map((node) => document.importNode(node, true)));
|
| 373 |
+
article._resolveSearchAnchor = readerContext.foliateSearchAnchor(source, sourceNodes, body, sectionBody);
|
| 374 |
+
shadow.append(baseStyle, ...styles, sectionBody);
|
| 375 |
+
for (const image of sectionBody.querySelectorAll("img")) {
|
| 376 |
+
image.loading = priority === "high" ? "eager" : "lazy";
|
| 377 |
+
image.fetchPriority = priority;
|
| 378 |
+
image.decoding = "async";
|
| 379 |
+
}
|
| 380 |
+
signal.throwIfAborted();
|
| 381 |
+
readerContext.assertReaderActive();
|
| 382 |
+
article.classList.toggle("reader-document-dark", readerContext.readerTheme === "dark");
|
| 383 |
+
return article;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
function setupFoliateWindow(stream, sections) {
|
| 387 |
+
let focus = readerContext.initialReaderTargetSection ?? 0, targetPending = false;
|
| 388 |
+
stream.style.overflowAnchor = "none";
|
| 389 |
+
const existing = index => stream.querySelector(`article[data-section="${index}"]:not(.foliate-section-placeholder)`);
|
| 390 |
+
const ensurePlaceholder = index => {
|
| 391 |
+
if (!sections[index] || stream.querySelector(`article[data-section="${index}"]`)) return;
|
| 392 |
+
const placeholder = document.createElement("article");
|
| 393 |
+
placeholder.className = "foliate-section-placeholder";
|
| 394 |
+
placeholder.dataset.section = String(index);
|
| 395 |
+
placeholder.style.setProperty("--foliate-placeholder-height", `${Math.max(400, readerContext.viewport.clientHeight)}px`);
|
| 396 |
+
placeholder.setAttribute("aria-label", `加载第 ${index + 1} 节`);
|
| 397 |
+
const next = [...stream.children].find(node => Number(node.dataset.section) > index);
|
| 398 |
+
readerContext.foliateScrollAnchors.preserve(() => stream.insertBefore(placeholder, next || null));
|
| 399 |
+
observer.observe(placeholder);
|
| 400 |
+
};
|
| 401 |
+
const warm = () => {
|
| 402 |
+
const indices = [];
|
| 403 |
+
for (const offset of [-1, 1, -2, 2, 3, 4, 5, 6]) {
|
| 404 |
+
const index = focus + offset;
|
| 405 |
+
if (sections[index] && !existing(index)) indices.push(index);
|
| 406 |
+
}
|
| 407 |
+
scheduler.prefetch(document.hidden ? [] : indices);
|
| 408 |
+
};
|
| 409 |
+
const refresh = () => {
|
| 410 |
+
if (readerContext.readerAbortController.signal.aborted) return;
|
| 411 |
+
const marker = readerContext.viewport.getBoundingClientRect().top + 80;
|
| 412 |
+
const rows = [...stream.children];
|
| 413 |
+
const current = rows.find(node => {
|
| 414 |
+
const rect = node.getBoundingClientRect();
|
| 415 |
+
return rect.top <= marker && rect.bottom > marker;
|
| 416 |
+
}) || rows.find(node => node.getBoundingClientRect().top > marker);
|
| 417 |
+
const index = current ? Number(current.dataset.section) : focus;
|
| 418 |
+
if (targetPending && index !== focus && !readerContext.foliateScrollAnchors.scrolling) return;
|
| 419 |
+
targetPending = false;
|
| 420 |
+
focus = index;
|
| 421 |
+
if (current?.classList.contains("foliate-section-placeholder")) scheduler.load(index).catch(() => {});
|
| 422 |
+
warm();
|
| 423 |
+
readerContext.foliateScrollAnchors.whenIdle(readerContext.trimFoliateSections);
|
| 424 |
+
};
|
| 425 |
+
const observer = new IntersectionObserver(readerContext.scheduleFoliateWindowRefresh,
|
| 426 |
+
{ root: readerContext.viewport, rootMargin: `${readerContext.FOLIATE_PREFETCH_MARGIN}px` });
|
| 427 |
+
readerContext.foliateSectionObserver = observer;
|
| 428 |
+
const commitSection = (index, article) =>
|
| 429 |
+
readerContext.foliateScrollAnchors.preserve(() => {
|
| 430 |
+
const placeholder = stream.querySelector(
|
| 431 |
+
`article.foliate-section-placeholder[data-section="${index}"]`
|
| 432 |
+
),
|
| 433 |
+
next = [...stream.querySelectorAll("article[data-section]")].find(
|
| 434 |
+
(item) => Number(item.dataset.section) > index
|
| 435 |
+
);
|
| 436 |
+
if (placeholder) {
|
| 437 |
+
observer.unobserve(placeholder);
|
| 438 |
+
placeholder.replaceWith(article);
|
| 439 |
+
} else stream.insertBefore(article, next || null);
|
| 440 |
+
observer.observe(article);
|
| 441 |
+
readerContext.foliateScrollAnchors.observe(article);
|
| 442 |
+
ensurePlaceholder(index - 1);
|
| 443 |
+
ensurePlaceholder(index + 1);
|
| 444 |
+
return article;
|
| 445 |
+
});
|
| 446 |
+
const scheduler = readerContext.readerRuntime.track(VoiceOfMLReaderChapters.createChapterScheduler({
|
| 447 |
+
create: async (index, signal, priority) => {
|
| 448 |
+
const article = await readerContext.createFoliateSection(sections[index], index, signal, priority);
|
| 449 |
+
signal.throwIfAborted();
|
| 450 |
+
if (existing(index)) return existing(index);
|
| 451 |
+
return commitSection(index, article);
|
| 452 |
+
}
|
| 453 |
+
}));
|
| 454 |
+
readerContext.foliateChapterRepository = VoiceOfMLReaderChapters.createChapterRepository({
|
| 455 |
+
count: sections.length, find: existing, create: index => scheduler.load(index)
|
| 456 |
+
});
|
| 457 |
+
readerContext.foliateSectionVirtualizer = VoiceOfMLReaderVirtual.createSectionVirtualizer({
|
| 458 |
+
limit: readerContext.FOLIATE_LOADED_SECTION_LIMIT,
|
| 459 |
+
getLoaded: () => [
|
| 460 |
+
...stream.querySelectorAll("article[data-section]:not(.foliate-section-placeholder)")
|
| 461 |
+
],
|
| 462 |
+
getIndex: (article) => Number(article.dataset.section),
|
| 463 |
+
getHeight: (article) => article.getBoundingClientRect().height,
|
| 464 |
+
canVirtualize: (article) => {
|
| 465 |
+
const rect = article.getBoundingClientRect(),
|
| 466 |
+
viewportRect = readerContext.viewport.getBoundingClientRect();
|
| 467 |
+
return (
|
| 468 |
+
rect.bottom < viewportRect.top - readerContext.FOLIATE_PREFETCH_MARGIN ||
|
| 469 |
+
rect.top > viewportRect.bottom + readerContext.FOLIATE_PREFETCH_MARGIN
|
| 470 |
+
) && !article.contains(document.activeElement) && !article.shadowRoot?.activeElement &&
|
| 471 |
+
!readerContext.foliateSectionRoot(article).querySelector("mark.full-search-highlight") &&
|
| 472 |
+
![window.getSelection(), article.shadowRoot?.getSelection?.()].some(selection =>
|
| 473 |
+
selection && !selection.isCollapsed && (selection.containsNode(article, true) ||
|
| 474 |
+
selection.anchorNode?.getRootNode() === article.shadowRoot));
|
| 475 |
+
},
|
| 476 |
+
virtualize: (article, index, height) => {
|
| 477 |
+
const placeholder = document.createElement("article");
|
| 478 |
+
placeholder.className = "foliate-section-placeholder";
|
| 479 |
+
placeholder.dataset.section = String(index);
|
| 480 |
+
placeholder.style.setProperty("--foliate-placeholder-height", `${height}px`);
|
| 481 |
+
placeholder.setAttribute("aria-hidden", "true");
|
| 482 |
+
observer.unobserve(article);
|
| 483 |
+
readerContext.foliateScrollAnchors.unobserve(article);
|
| 484 |
+
for (const image of readerContext.foliateSectionRoot(article).querySelectorAll("img")) {
|
| 485 |
+
image.removeAttribute("src");
|
| 486 |
+
image.removeAttribute("srcset");
|
| 487 |
+
}
|
| 488 |
+
article._resolveSearchAnchor = null;
|
| 489 |
+
article.replaceWith(placeholder);
|
| 490 |
+
observer.observe(placeholder);
|
| 491 |
+
return placeholder;
|
| 492 |
+
},
|
| 493 |
+
release: (index) => readerContext.foliateChapterRepository.release(index),
|
| 494 |
+
preserve: (change) => readerContext.foliateScrollAnchors.preserve(change)
|
| 495 |
+
});
|
| 496 |
+
readerContext.foliateSectionLoader = index => {
|
| 497 |
+
focus = index;
|
| 498 |
+
targetPending = true;
|
| 499 |
+
const task = existing(index) ? Promise.resolve(existing(index)) : scheduler.load(index, true, true);
|
| 500 |
+
warm();
|
| 501 |
+
return task;
|
| 502 |
+
};
|
| 503 |
+
readerContext.foliateSectionLoader.refresh = refresh;
|
| 504 |
+
readerContext.foliateSectionSettler = () => readerContext.waitForReader(0, true);
|
| 505 |
+
const scroll = readerContext.scheduleFoliateWindowRefresh;
|
| 506 |
+
readerContext.viewport.addEventListener("scroll", scroll, { passive: true });
|
| 507 |
+
document.addEventListener("visibilitychange", scroll);
|
| 508 |
+
readerContext.trackReaderResource(() => {
|
| 509 |
+
readerContext.viewport.removeEventListener("scroll", scroll);
|
| 510 |
+
document.removeEventListener("visibilitychange", scroll);
|
| 511 |
+
});
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
async function foliateTocEntries(view, sections) {
|
| 515 |
+
readerContext.assertReaderActive();
|
| 516 |
+
const entries = [];
|
| 517 |
+
const append = (items, depth = 0) => {
|
| 518 |
+
for (const item of items || []) {
|
| 519 |
+
const href = item?.href ? String(item.href) : "";
|
| 520 |
+
if (href || item?.label)
|
| 521 |
+
entries.push({
|
| 522 |
+
label: item.label || "未命名章节",
|
| 523 |
+
href,
|
| 524 |
+
depth,
|
| 525 |
+
group: !href,
|
| 526 |
+
activate: () => view.goTo(href)
|
| 527 |
+
});
|
| 528 |
+
append(item.subitems, depth + 1);
|
| 529 |
+
}
|
| 530 |
+
};
|
| 531 |
+
append(view.book.toc);
|
| 532 |
+
const sectionIndices = new Map();
|
| 533 |
+
for (const [index, section] of sections.entries())
|
| 534 |
+
if (!sectionIndices.has(section)) sectionIndices.set(section, index);
|
| 535 |
+
let next = 0, yieldDeadline = performance.now() + 8, yieldTask = null;
|
| 536 |
+
const resolveEntries = async () => {
|
| 537 |
+
while (next < entries.length) {
|
| 538 |
+
readerContext.assertReaderActive();
|
| 539 |
+
if (yieldTask || performance.now() >= yieldDeadline) {
|
| 540 |
+
yieldTask ||= readerContext.waitForReader().finally(() => {
|
| 541 |
+
yieldTask = null;
|
| 542 |
+
yieldDeadline = performance.now() + 8;
|
| 543 |
+
});
|
| 544 |
+
await yieldTask;
|
| 545 |
+
continue;
|
| 546 |
+
}
|
| 547 |
+
const entry = entries[next++];
|
| 548 |
+
if (entry.group) {
|
| 549 |
+
entry.sectionIndex = -1;
|
| 550 |
+
entry.fragment = "";
|
| 551 |
+
continue;
|
| 552 |
+
}
|
| 553 |
+
try {
|
| 554 |
+
const target = await view.book.resolveHref(entry.href),
|
| 555 |
+
section = target && view.book.sections[target.index];
|
| 556 |
+
readerContext.assertReaderActive();
|
| 557 |
+
entry.sectionIndex = section ? sectionIndices.get(section) ?? -1 : -1;
|
| 558 |
+
entry.anchor = typeof target?.anchor === "function" ? target.anchor : null;
|
| 559 |
+
const fragment = entry.href.split("#")[1] || "";
|
| 560 |
+
entry.fragment = fragment ? decodeURIComponent(fragment) : "";
|
| 561 |
+
} catch (_) {
|
| 562 |
+
readerContext.assertReaderActive();
|
| 563 |
+
entry.sectionIndex = -1;
|
| 564 |
+
entry.fragment = "";
|
| 565 |
+
}
|
| 566 |
+
}
|
| 567 |
+
};
|
| 568 |
+
await readerContext.awaitReader(Promise.all(Array.from({ length: Math.min(8, entries.length) }, resolveEntries)));
|
| 569 |
+
readerContext.assertReaderActive();
|
| 570 |
+
return entries;
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
async function renderFoliate() {
|
| 574 |
+
const prepared = readerContext.prepareReaderDocument([import("/static/foliate-reader/view.js")]);
|
| 575 |
+
const [response, bytes] = await Promise.all([prepared.response,readerContext.readPreparedBytes(
|
| 576 |
+
prepared.response,prepared.engines,VoiceOfMLReaderSecurity.LIMITS.archiveCompressedBytes)]);
|
| 577 |
+
readerContext.assertReaderActive();
|
| 578 |
+
if (VoiceOfMLReaderSecurity.isZipContainer(readerContext.extension, bytes))
|
| 579 |
+
VoiceOfMLReaderSecurity.inspectZip(bytes);
|
| 580 |
+
const view = document.createElement("foliate-view"),
|
| 581 |
+
stream = document.createElement("div");
|
| 582 |
+
view.className = "foliate-reader-view";
|
| 583 |
+
stream.className = "foliate-continuous";
|
| 584 |
+
readerContext.content.replaceChildren(readerContext.loadingIndicator, view, stream);
|
| 585 |
+
readerContext.epubRendition = view;
|
| 586 |
+
await view.open(
|
| 587 |
+
new File([bytes], new URL(readerContext.sourceUrl).pathname.split("/").pop() || "book.epub", {
|
| 588 |
+
type: response.headers.get("content-type") || "application/epub+zip"
|
| 589 |
+
})
|
| 590 |
+
);
|
| 591 |
+
readerContext.assertReaderActive();
|
| 592 |
+
readerContext.epubBook = view.book;
|
| 593 |
+
view.style.display = "none";
|
| 594 |
+
const sections = view.book.sections.filter((section) => section.linear !== "no");
|
| 595 |
+
await readerContext.getInitialReaderRestoration();
|
| 596 |
+
const savedSection = Number(readerContext.readerRestorationEntry?.foliateSection);
|
| 597 |
+
const initialSection = Number.isInteger(savedSection)
|
| 598 |
+
? Math.max(0, Math.min(sections.length - 1, savedSection))
|
| 599 |
+
: 0;
|
| 600 |
+
readerContext.initialReaderTargetSection = readerContext.readerRestorationEntry?.foliateSection == null ? null : initialSection;
|
| 601 |
+
readerContext.setupFoliateWindow(stream, sections);
|
| 602 |
+
if (sections[initialSection]) await readerContext.foliateChapterRepository.load(initialSection);
|
| 603 |
+
if (initialSection > 0) await readerContext.foliateChapterRepository.load(initialSection - 1);
|
| 604 |
+
readerContext.assertReaderActive();
|
| 605 |
+
// Resolving every TOC href can be expensive for large books. The first
|
| 606 |
+
// chapter is already visible, so finish the opening path before enriching
|
| 607 |
+
// the navigation panel in the background.
|
| 608 |
+
readerContext.foliateTocEntries(view, sections)
|
| 609 |
+
.then((entries) => {
|
| 610 |
+
if (entries.length && !readerContext.readerAbortController.signal.aborted) readerContext.setToc(entries);
|
| 611 |
+
})
|
| 612 |
+
.catch(() => {});
|
| 613 |
+
readerContext.scheduleFoliateScrollSync();
|
| 614 |
+
readerContext.markReaderContentReady();
|
| 615 |
+
readerContext.status.textContent = "EPUB";
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
return {
|
| 619 |
+
foliateSectionRoot,
|
| 620 |
+
foliateSectionQuery,
|
| 621 |
+
foliateSectionCandidates,
|
| 622 |
+
syncFoliateScrollLocation,
|
| 623 |
+
refreshFoliateWindow,
|
| 624 |
+
scheduleFoliateWindowRefresh,
|
| 625 |
+
trimFoliateSections,
|
| 626 |
+
scheduleFoliateScrollSync,
|
| 627 |
+
activateFoliateTocEntry,
|
| 628 |
+
cloneFoliateDocument,
|
| 629 |
+
foliateSearchAnchor,
|
| 630 |
+
settleReaderImages,
|
| 631 |
+
rewriteFoliateResources,
|
| 632 |
+
createFoliateSection,
|
| 633 |
+
setupFoliateWindow,
|
| 634 |
+
foliateTocEntries,
|
| 635 |
+
renderFoliate
|
| 636 |
+
};
|
| 637 |
+
}
|
static/{reader-format-adapters.js → reader/formats/reader-format-adapters.js}
RENAMED
|
File without changes
|
static/reader/formats/reader-format-controller.mjs
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function createReaderFormatController(readerContext) {
|
| 2 |
+
function disposeFormatResources(mode) {
|
| 3 |
+
if (mode === "epub-chapters") {
|
| 4 |
+
readerContext.chapterManifestCleanup?.();
|
| 5 |
+
readerContext.chapterManifestCleanup = null;
|
| 6 |
+
}
|
| 7 |
+
if (mode === "swf") {
|
| 8 |
+
readerContext.swfPlayer?.remove();
|
| 9 |
+
readerContext.swfPlayer = null;
|
| 10 |
+
}
|
| 11 |
+
if (["pdf", "pdf-pages", "image-pages"].includes(mode)) {
|
| 12 |
+
readerContext.pdfDocument = null;
|
| 13 |
+
readerContext.pdfHybridUrl = "";
|
| 14 |
+
readerContext.pdfHybridDocumentPromise = null;
|
| 15 |
+
readerContext.pdfPageManifest = null;
|
| 16 |
+
readerContext.pdfOcrManifest = null;
|
| 17 |
+
readerContext.pdfOcrManifestPromise = null;
|
| 18 |
+
readerContext.pdfV3Repository?.dispose(); readerContext.pdfTextLayerRepository?.dispose();
|
| 19 |
+
for (const shell of readerContext.pdfManifestShells) shell?._v3PreviewRelease?.();
|
| 20 |
+
readerContext.pdfV3Repository = readerContext.pdfTextLayerRepository = null;
|
| 21 |
+
readerContext.pdfV3Manifest = readerContext.pdfV3Map = null;
|
| 22 |
+
readerContext.pdfTextContentCache.clear();
|
| 23 |
+
readerContext.pdfTextContentPromises.clear();
|
| 24 |
+
readerContext.pdfTextContentCacheBytes = 0;
|
| 25 |
+
readerContext.pdfOcrPageCache.clear();
|
| 26 |
+
readerContext.pdfOcrPageCacheBytes = 0;
|
| 27 |
+
readerContext.clearPdfManifestPrefetches();
|
| 28 |
+
readerContext.pdfBookSearchClient?.dispose();
|
| 29 |
+
readerContext.pdfBookSearchClient = null;
|
| 30 |
+
readerContext.pdfShellWindow?.dispose();
|
| 31 |
+
readerContext.pdfFirstPagePreload?.removeAttribute("src");
|
| 32 |
+
readerContext.pdfFirstPagePreload = null;
|
| 33 |
+
for (const canvas of readerContext.content.querySelectorAll(".reader-page canvas")) {
|
| 34 |
+
canvas.width = 0;
|
| 35 |
+
canvas.height = 0;
|
| 36 |
+
}
|
| 37 |
+
for (const image of readerContext.content.querySelectorAll(".reader-page img")) image.removeAttribute("src");
|
| 38 |
+
}
|
| 39 |
+
if (mode === "foliate") {
|
| 40 |
+
readerContext.foliateSectionObserver?.disconnect();
|
| 41 |
+
readerContext.foliateSectionObserver = null;
|
| 42 |
+
readerContext.foliateSectionVirtualizer?.dispose();
|
| 43 |
+
readerContext.foliateChapterRepository?.dispose();
|
| 44 |
+
readerContext.foliateSectionVirtualizer = null;
|
| 45 |
+
readerContext.foliateChapterRepository = null;
|
| 46 |
+
readerContext.foliateSectionLoader = null;
|
| 47 |
+
readerContext.foliateSectionSettler = null;
|
| 48 |
+
try {
|
| 49 |
+
const closing = readerContext.epubRendition?.close?.();
|
| 50 |
+
closing?.catch?.(() => {});
|
| 51 |
+
} catch (_) {}
|
| 52 |
+
readerContext.epubRendition = null;
|
| 53 |
+
readerContext.epubBook = null;
|
| 54 |
+
}
|
| 55 |
+
if (mode === "html") {
|
| 56 |
+
readerContext.htmlFrame?.remove();
|
| 57 |
+
readerContext.htmlFrame = null;
|
| 58 |
+
readerContext.content.classList.remove("reader-content--spreadsheet");
|
| 59 |
+
}
|
| 60 |
+
if (["audio", "video"].includes(mode) && readerContext.mediaElement) {
|
| 61 |
+
readerContext.mediaElement.pause();
|
| 62 |
+
readerContext.mediaElement.removeAttribute("src");
|
| 63 |
+
readerContext.mediaElement.load();
|
| 64 |
+
readerContext.mediaElement = null;
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function registerReaderFormatAdapters() {
|
| 69 |
+
const formats = {
|
| 70 |
+
"pdf-pages": [
|
| 71 |
+
() => {
|
| 72 |
+
return readerContext.fetchReaderResponse();
|
| 73 |
+
},
|
| 74 |
+
readerContext.renderPdfPages
|
| 75 |
+
],
|
| 76 |
+
"image-pages": [
|
| 77 |
+
() => readerContext.fetchReaderResponse(),
|
| 78 |
+
readerContext.renderImagePages
|
| 79 |
+
],
|
| 80 |
+
pdf: [readerContext.loadPdfDocument, readerContext.renderPdf],
|
| 81 |
+
image: [readerContext.loadImageDocument, readerContext.renderImageDocument],
|
| 82 |
+
text: [readerContext.loadTextDocument, readerContext.renderTextDocument],
|
| 83 |
+
markdown: [readerContext.loadMarkdownDocument, readerContext.renderMarkdownDocument],
|
| 84 |
+
html: [readerContext.loadHtmlDocument, readerContext.renderHtml],
|
| 85 |
+
"epub-chapters": [readerContext.loadChapterManifestDocument, readerContext.renderChapterManifest],
|
| 86 |
+
docx: [readerContext.loadDocxDocument, readerContext.renderDocx],
|
| 87 |
+
audio: [readerContext.loadMediaDocument, () => readerContext.renderMedia("audio")],
|
| 88 |
+
video: [readerContext.loadMediaDocument, () => readerContext.renderMedia("video")],
|
| 89 |
+
swf: [readerContext.loadSwfDocument, readerContext.renderSwf],
|
| 90 |
+
foliate: [readerContext.loadMediaDocument, readerContext.renderFoliate]
|
| 91 |
+
};
|
| 92 |
+
for (const [mode, [open, render]] of Object.entries(formats))
|
| 93 |
+
readerContext.formatAdapters.register(mode, {
|
| 94 |
+
open,
|
| 95 |
+
render,
|
| 96 |
+
restore: (entry, generation) => readerContext.restoreFormat(mode, entry, generation),
|
| 97 |
+
dispose: () => readerContext.disposeFormatResources(mode)
|
| 98 |
+
});
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
async function resolveReaderSource() {
|
| 102 |
+
if (!readerContext.readerId || readerContext.sourceUrl) return;
|
| 103 |
+
readerContext.setReaderStage("resolve");
|
| 104 |
+
let resolved = readerContext.cachedReaderData;
|
| 105 |
+
if (!resolved?.url) {
|
| 106 |
+
try {
|
| 107 |
+
resolved = await readerContext.resolveReaderId(readerContext.readerId);
|
| 108 |
+
} catch (error) {
|
| 109 |
+
readerContext.assertReaderActive();
|
| 110 |
+
if (!readerContext.localReaderData?.url) throw error;
|
| 111 |
+
resolved = readerContext.localReaderData;
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
readerContext.assertReaderActive();
|
| 115 |
+
readerContext.sourceUrl = readerContext.normalizeSourceUrl(resolved.url || "");
|
| 116 |
+
readerContext.contentUrl = readerContext.readerContentUrl(readerContext.sourceUrl);
|
| 117 |
+
readerContext.downloadUrl = resolved.download || readerContext.sourceUrl;
|
| 118 |
+
readerContext.chapterManifestUrl = resolved.chapter_manifest || "";
|
| 119 |
+
readerContext.fallbackUrl = resolved.fallback || "";
|
| 120 |
+
if (resolved.extension) readerContext.extension = String(resolved.extension).toLowerCase();
|
| 121 |
+
if (
|
| 122 |
+
readerContext.extension === "pdf" &&
|
| 123 |
+
readerContext.sourceUrl.includes("/api/reader-bucket-resource?") &&
|
| 124 |
+
new URL(readerContext.sourceUrl).searchParams.get("path")?.endsWith("page-manifest.json")
|
| 125 |
+
)
|
| 126 |
+
readerContext.extension = "pdf-pages";
|
| 127 |
+
readerContext.capability = readerContext.readerRuntime.negotiate(VoiceOfMLReader.capability(readerContext.extension));
|
| 128 |
+
if (readerContext.capability.mode !== "pdf" && !readerContext.pdfPrimarySource()) readerContext.releasePdfWorker?.();
|
| 129 |
+
readerContext.readerRuntime.update("source", {
|
| 130 |
+
url: readerContext.sourceUrl,
|
| 131 |
+
contentUrl: readerContext.contentUrl,
|
| 132 |
+
downloadUrl: readerContext.downloadUrl,
|
| 133 |
+
extension: readerContext.extension,
|
| 134 |
+
metadata: resolved
|
| 135 |
+
});
|
| 136 |
+
readerContext.applyReaderMetadata(resolved);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
async function start() {
|
| 140 |
+
const generation = readerContext.readerRuntime.currentGeneration("navigation");
|
| 141 |
+
// A PDF link already tells us which engine is needed. Start its import while
|
| 142 |
+
// an ID-only Reader link is resolving its source URL. The promise is reused
|
| 143 |
+
// by loadPdfDocument, so this does not create a second module request.
|
| 144 |
+
if (readerContext.extension === "pdf" || /\.pdf(?:$|[?#])/i.test(readerContext.sourceUrl)) readerContext.preloadPdfEngine().catch(() => {});
|
| 145 |
+
readerContext.readerSourcePromise = readerContext.measurePdf(readerContext.resolveReaderSource(), "resolve");
|
| 146 |
+
await readerContext.readerSourcePromise;
|
| 147 |
+
readerContext.assertReaderActive();
|
| 148 |
+
if (readerContext.readerId && /\/calibre-chm-epub-[^/]+\/document\.epub(?:$|\?)/i.test(readerContext.sourceUrl)) {
|
| 149 |
+
readerContext.extension = "epub";
|
| 150 |
+
readerContext.readerRuntime.update("source", { extension: readerContext.extension });
|
| 151 |
+
readerContext.capability = readerContext.readerRuntime.negotiate(VoiceOfMLReader.capability(readerContext.extension));
|
| 152 |
+
readerContext.content.dataset.mode = readerContext.capability.mode;
|
| 153 |
+
document.querySelector(".page-controls").hidden = true;
|
| 154 |
+
}
|
| 155 |
+
readerContext.syncCapabilityControls();
|
| 156 |
+
readerContext.downloadButton.href = readerContext.readerDownloadUrl(readerContext.documentState.title, readerContext.downloadUrl);
|
| 157 |
+
if (
|
| 158 |
+
(!readerContext.validSource(readerContext.sourceUrl) && !readerContext.validSource(readerContext.chapterManifestUrl) && !readerContext.readerId) ||
|
| 159 |
+
readerContext.capability.readerMode === VoiceOfMLReader.ReaderMode.UNSUPPORTED
|
| 160 |
+
)
|
| 161 |
+
return readerContext.fail("此文件暂不支持在线阅读,请下载原文件。", "READER_UNSUPPORTED");
|
| 162 |
+
readerContext.formatAdapters.activate(readerContext.capability.mode);
|
| 163 |
+
readerContext.applyReaderMetadata(readerContext.resolvedReaderData);
|
| 164 |
+
if (readerContext.validOcr(readerContext.ocrUrl)) {
|
| 165 |
+
const ocr = document.querySelector("#ocr") || document.createElement("a");
|
| 166 |
+
ocr.id = "ocr";
|
| 167 |
+
ocr.className = "text-button";
|
| 168 |
+
ocr.target = "_blank";
|
| 169 |
+
ocr.rel = "noopener noreferrer";
|
| 170 |
+
ocr.textContent = "OCR";
|
| 171 |
+
if (!ocr.parentElement) document.querySelector(".reader-actions")?.prepend(ocr);
|
| 172 |
+
ocr.href = readerContext.ocrUrl;
|
| 173 |
+
ocr.hidden = false;
|
| 174 |
+
}
|
| 175 |
+
try {
|
| 176 |
+
if (readerContext.capability.mode === "foliate") {
|
| 177 |
+
readerContext.setReaderStage("foliate");
|
| 178 |
+
readerContext.foliateContinuous = true;
|
| 179 |
+
const restorationPromise = readerContext.loadReaderRestoration();
|
| 180 |
+
readerContext.readerRestorationPromise = restorationPromise;
|
| 181 |
+
await readerContext.awaitReader(readerContext.renderFoliate());
|
| 182 |
+
readerContext.assertReaderActive();
|
| 183 |
+
const restored = await readerContext.awaitReader(restorationPromise);
|
| 184 |
+
await readerContext.applyInitialRestoration(restored, generation);
|
| 185 |
+
readerContext.initialReaderTargetSection = null;
|
| 186 |
+
readerContext.assertReaderActive();
|
| 187 |
+
if (!readerContext.setReaderPhase("ready")) return;
|
| 188 |
+
readerContext.updateDocumentState({ restorationReady: !readerContext.restorationFailed });
|
| 189 |
+
readerContext.scheduleSave();
|
| 190 |
+
return;
|
| 191 |
+
}
|
| 192 |
+
readerContext.setReaderStage("prepare");
|
| 193 |
+
const restorationPromise = readerContext.loadReaderRestoration();
|
| 194 |
+
readerContext.readerRestorationPromise = restorationPromise;
|
| 195 |
+
const prepareResources = new Set(readerContext.readerResources);
|
| 196 |
+
try {
|
| 197 |
+
const prepared = await readerContext.awaitReader(readerContext.formatAdapters.active.open());
|
| 198 |
+
readerContext.assertReaderActive();
|
| 199 |
+
await readerContext.formatAdapters.active.render(prepared);
|
| 200 |
+
} catch (error) {
|
| 201 |
+
readerContext.assertReaderActive();
|
| 202 |
+
const originalExtension = String(readerContext.resolvedReaderData?.original_extension || "").toLowerCase();
|
| 203 |
+
if (readerContext.capability.mode !== "image-pages" || !readerContext.validSource(readerContext.downloadUrl) ||
|
| 204 |
+
!["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(originalExtension)) throw error;
|
| 205 |
+
for (const cleanup of [...readerContext.readerResources]) {
|
| 206 |
+
if (prepareResources.has(cleanup)) continue;
|
| 207 |
+
cleanup();
|
| 208 |
+
readerContext.readerResources.delete(cleanup);
|
| 209 |
+
}
|
| 210 |
+
readerContext.formatAdapters.dispose();
|
| 211 |
+
readerContext.content.replaceChildren();
|
| 212 |
+
readerContext.extension = originalExtension;
|
| 213 |
+
readerContext.capability = readerContext.readerRuntime.negotiate(VoiceOfMLReader.capability(readerContext.extension));
|
| 214 |
+
readerContext.readerRuntime.update("source", { extension: readerContext.extension });
|
| 215 |
+
readerContext.updateDocumentState({ page: 1, pageCount: 0 });
|
| 216 |
+
readerContext.formatAdapters.activate(readerContext.capability.mode);
|
| 217 |
+
readerContext.syncCapabilityControls();
|
| 218 |
+
await readerContext.renderImageDocument(await readerContext.loadImageDocument(readerContext.downloadUrl));
|
| 219 |
+
}
|
| 220 |
+
readerContext.assertReaderActive();
|
| 221 |
+
readerContext.markReaderContentReady();
|
| 222 |
+
const restored = await readerContext.awaitReader(restorationPromise);
|
| 223 |
+
await readerContext.applyInitialRestoration(restored, generation);
|
| 224 |
+
readerContext.assertReaderActive();
|
| 225 |
+
if (!readerContext.setReaderPhase("ready")) return;
|
| 226 |
+
readerContext.updateDocumentState({ restorationReady: !readerContext.restorationFailed });
|
| 227 |
+
readerContext.schedulePdfBookSearchPrefetch();
|
| 228 |
+
readerContext.updateProgressTools();
|
| 229 |
+
if (readerContext.fullSearchInput.value.trim()) readerContext.runFullSearch();
|
| 230 |
+
readerContext.scheduleSave();
|
| 231 |
+
} catch (error) {
|
| 232 |
+
if (readerContext.readerAbortController.signal.aborted || error?.name === "AbortError") return;
|
| 233 |
+
console.error(error);
|
| 234 |
+
if (
|
| 235 |
+
error?.code !== "READER_RESTORE" &&
|
| 236 |
+
readerContext.capability.mode === "epub-chapters" &&
|
| 237 |
+
readerContext.validFallback(readerContext.fallbackUrl)
|
| 238 |
+
) {
|
| 239 |
+
const target = new URL(location.href);
|
| 240 |
+
target.searchParams.set("url", readerContext.fallbackUrl);
|
| 241 |
+
target.searchParams.set("ext", "pdf");
|
| 242 |
+
target.searchParams.delete("chapter_manifest");
|
| 243 |
+
target.searchParams.delete("fallback");
|
| 244 |
+
if (window.parent !== window)
|
| 245 |
+
window.parent.postMessage({ type: "voice-reader-open", url: target.href }, location.origin);
|
| 246 |
+
else location.replace(target.href);
|
| 247 |
+
return;
|
| 248 |
+
}
|
| 249 |
+
readerContext.fail(
|
| 250 |
+
error && error.message ? error.message : "原文件加载失败",
|
| 251 |
+
readerContext.classifyReaderError(error, readerContext.capability.mode === "foliate" ? "READER_CORRUPT" : "READER_PARSE")
|
| 252 |
+
);
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
function syncCapabilityControls() {
|
| 257 |
+
readerContext.content.dataset.mode = readerContext.capability.mode || "unsupported";
|
| 258 |
+
readerContext.pageInvertToggle.hidden = !["pdf", "pdf-pages"].includes(readerContext.capability.mode);
|
| 259 |
+
document.querySelector(".page-controls").hidden = !readerContext.capability.features.pagination;
|
| 260 |
+
document.querySelector(".zoom-controls").hidden = !readerContext.capability.features.zoom;
|
| 261 |
+
readerContext.mediaTab.hidden = !readerContext.capability.features.media;
|
| 262 |
+
readerContext.fullSearchButton.hidden = !readerContext.capability.features.search || (readerContext.capability.mode === "pdf-pages" && !readerContext.ocrManifestUrl && !readerContext.pdfHybridUrl);
|
| 263 |
+
readerContext.bookmarkRibbon.hidden = !readerContext.capability.features.bookmarks;
|
| 264 |
+
readerContext.readingProgressSummary.querySelector(".reader-progress-bookmark").hidden =
|
| 265 |
+
!readerContext.capability.features.bookmarks;
|
| 266 |
+
if (
|
| 267 |
+
(readerContext.panelState.selected === "media" && readerContext.mediaTab.hidden) ||
|
| 268 |
+
(readerContext.panelState.selected === "full-search" && readerContext.fullSearchButton.hidden)
|
| 269 |
+
)
|
| 270 |
+
readerContext.selectPanel("toc");
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
return {
|
| 274 |
+
disposeFormatResources,
|
| 275 |
+
registerReaderFormatAdapters,
|
| 276 |
+
resolveReaderSource,
|
| 277 |
+
start,
|
| 278 |
+
syncCapabilityControls
|
| 279 |
+
};
|
| 280 |
+
}
|
static/reader/formats/reader-html-format.mjs
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function createReaderHtmlFormat(readerContext) {
|
| 2 |
+
function normalizeEpubDocument(doc) {
|
| 3 |
+
// Legacy CHM conversions can use an XHTML prefix even in HTML content.
|
| 4 |
+
for (const element of [...doc.querySelectorAll("*")]) {
|
| 5 |
+
const tagName = String(element.tagName || "").toLowerCase();
|
| 6 |
+
const localName = tagName.includes(":") ? tagName.slice(tagName.lastIndexOf(":") + 1) : tagName;
|
| 7 |
+
if (tagName === localName || !readerContext.EPUB_HTML_TAGS.has(localName)) continue;
|
| 8 |
+
const replacement = doc.createElementNS("http://www.w3.org/1999/xhtml", localName);
|
| 9 |
+
for (const attribute of [...element.attributes]) {
|
| 10 |
+
if (!attribute.name.toLowerCase().startsWith("xmlns:"))
|
| 11 |
+
replacement.setAttribute(attribute.name, attribute.value);
|
| 12 |
+
}
|
| 13 |
+
// HTML parsing treats prefixed void tags as containers. Keep the following
|
| 14 |
+
// content as siblings: serializing real <br>/<hr>/<img> drops their children.
|
| 15 |
+
// Move the existing text nodes so Foliate's source/CFI mapping stays intact.
|
| 16 |
+
const fragment = doc.createDocumentFragment();
|
| 17 |
+
fragment.appendChild(replacement);
|
| 18 |
+
if (readerContext.EPUB_HTML_VOID_TAGS.has(localName)) {
|
| 19 |
+
while (element.firstChild) fragment.appendChild(element.firstChild);
|
| 20 |
+
} else {
|
| 21 |
+
while (element.firstChild) replacement.appendChild(element.firstChild);
|
| 22 |
+
}
|
| 23 |
+
element.replaceWith(fragment);
|
| 24 |
+
}
|
| 25 |
+
return doc;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function sanitizeReaderDocument(doc, { preserveEpubStyles = false } = {}) {
|
| 29 |
+
const blocked = preserveEpubStyles
|
| 30 |
+
? "script,iframe,object,embed,base,form"
|
| 31 |
+
: "script,iframe,object,embed,base,form,link,style";
|
| 32 |
+
for (const node of doc.querySelectorAll(blocked)) node.remove();
|
| 33 |
+
if (preserveEpubStyles) {
|
| 34 |
+
for (const link of doc.querySelectorAll("link")) {
|
| 35 |
+
const rel = (link.getAttribute("rel") || "").toLowerCase().split(/\s+/);
|
| 36 |
+
if (!rel.includes("stylesheet") || !/^blob:/i.test(link.getAttribute("href") || ""))
|
| 37 |
+
link.remove();
|
| 38 |
+
}
|
| 39 |
+
for (const style of doc.querySelectorAll("style")) {
|
| 40 |
+
if (
|
| 41 |
+
/(?:@import\s+|url\s*\(\s*[\"']?)(?:https?:|\/\/|javascript:)/i.test(
|
| 42 |
+
style.textContent || ""
|
| 43 |
+
)
|
| 44 |
+
)
|
| 45 |
+
style.remove();
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
for (const element of doc.querySelectorAll("*"))
|
| 49 |
+
for (const attribute of [...element.attributes]) {
|
| 50 |
+
const name = attribute.name.toLowerCase(),
|
| 51 |
+
value = String(attribute.value || "").trim();
|
| 52 |
+
if (
|
| 53 |
+
name.startsWith("on") ||
|
| 54 |
+
name === "srcdoc" ||
|
| 55 |
+
((name === "href" || name === "xlink:href" || name === "src") &&
|
| 56 |
+
/^javascript:/i.test(value))
|
| 57 |
+
)
|
| 58 |
+
element.removeAttribute(attribute.name);
|
| 59 |
+
}
|
| 60 |
+
return doc;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function sanitizeEpubDocument(doc) {
|
| 64 |
+
return readerContext.sanitizeReaderDocument(readerContext.normalizeEpubDocument(doc), { preserveEpubStyles: true });
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function parseChapterDocument(text) {
|
| 68 |
+
// Parse generated XHTML before HTML's void-element rules can swallow children
|
| 69 |
+
// of prefixed elements such as <html:meta/> (including the following title).
|
| 70 |
+
const xml = new DOMParser().parseFromString(text, "application/xhtml+xml");
|
| 71 |
+
if (!xml.querySelector("parsererror"))
|
| 72 |
+
text = new XMLSerializer().serializeToString(readerContext.normalizeEpubDocument(xml));
|
| 73 |
+
return readerContext.sanitizeEpubDocument(new DOMParser().parseFromString(text, "text/html"));
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function epubContentBody(doc) {
|
| 77 |
+
const nestedBody = [...doc.querySelectorAll("body")].find((body) => body !== doc.body);
|
| 78 |
+
return nestedBody || doc.body || doc.documentElement;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
async function renderText(markdown, prepared) {
|
| 82 |
+
let response = await prepared.response;
|
| 83 |
+
if (!response.ok) response = await readerContext.fetchReaderResponse();
|
| 84 |
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 85 |
+
if (!markdown) await readerContext.renderPlainText(response);
|
| 86 |
+
else {
|
| 87 |
+
const bytes = await readerContext.readPreparedBytes(response, prepared.engines);
|
| 88 |
+
const text = new TextDecoder(readerContext.detectTextEncoding(bytes, readerContext.documentState.title)).decode(bytes);
|
| 89 |
+
const article = document.createElement("article");
|
| 90 |
+
article.className = "reader-markdown";
|
| 91 |
+
article.innerHTML = DOMPurify.sanitize(marked.parse(text), { USE_PROFILES: { html: true } });
|
| 92 |
+
readerContext.content.appendChild(article);
|
| 93 |
+
readerContext.setToc(readerContext.headingTocEntries(article));
|
| 94 |
+
}
|
| 95 |
+
readerContext.markReaderContentReady();
|
| 96 |
+
readerContext.status.textContent = "已加载";
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function sanitizeOfflineHtml(text) {
|
| 100 |
+
const clean = DOMPurify.sanitize(text, {
|
| 101 |
+
USE_PROFILES: { html: true },
|
| 102 |
+
ADD_TAGS: ["style"],
|
| 103 |
+
FORBID_TAGS: ["base", "embed", "form", "iframe", "object", "script"],
|
| 104 |
+
FORBID_ATTR: ["action", "formaction", "srcdoc"],
|
| 105 |
+
ALLOWED_URI_REGEXP: /^data:image\/(?:gif|png|jpeg|webp|svg\+xml);/i
|
| 106 |
+
});
|
| 107 |
+
const template = document.createElement("template");
|
| 108 |
+
template.innerHTML = clean;
|
| 109 |
+
const stripUrls = (value) => value.replace(/@import[^;]+;|url\s*\([^)]*\)/gi, "");
|
| 110 |
+
for (const style of template.content.querySelectorAll("style"))
|
| 111 |
+
style.textContent = stripUrls(style.textContent);
|
| 112 |
+
for (const element of template.content.querySelectorAll("[style]"))
|
| 113 |
+
element.setAttribute("style", stripUrls(element.getAttribute("style")));
|
| 114 |
+
return template.innerHTML;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function buildDiscourseDocument(text) {
|
| 118 |
+
try {
|
| 119 |
+
const source = new DOMParser().parseFromString(text, "text/html");
|
| 120 |
+
const posts = [...source.querySelectorAll(readerContext.DISCOURSE_POST_SELECTOR)];
|
| 121 |
+
if (!source.querySelector(".post-stream") || !posts.length) return null;
|
| 122 |
+
const article = source.createElement("article");
|
| 123 |
+
article.className = "reader-forum-document";
|
| 124 |
+
const titleNode = source.querySelector(readerContext.DISCOURSE_TITLE_SELECTOR);
|
| 125 |
+
const title = titleNode?.textContent?.trim();
|
| 126 |
+
if (title) {
|
| 127 |
+
const heading = source.createElement("h1");
|
| 128 |
+
heading.textContent = title;
|
| 129 |
+
article.appendChild(heading);
|
| 130 |
+
}
|
| 131 |
+
posts.forEach((post, index) => {
|
| 132 |
+
const section = source.createElement("section");
|
| 133 |
+
section.className = "reader-forum-post";
|
| 134 |
+
section.dataset.post = String(index + 1);
|
| 135 |
+
section.innerHTML = post.innerHTML;
|
| 136 |
+
article.appendChild(section);
|
| 137 |
+
});
|
| 138 |
+
return article.outerHTML;
|
| 139 |
+
} catch (_) {
|
| 140 |
+
return null;
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
function prepareHtmlDocument(text) {
|
| 145 |
+
const discourse = readerContext.buildDiscourseDocument(text);
|
| 146 |
+
const html = readerContext.sanitizeOfflineHtml(discourse || text);
|
| 147 |
+
return {
|
| 148 |
+
html,
|
| 149 |
+
discourse: !!discourse,
|
| 150 |
+
spreadsheet: !discourse && html.includes("reader-spreadsheet-sheet")
|
| 151 |
+
};
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function buildHtmlFrameSource(documentData) {
|
| 155 |
+
return documentData.html + readerContext.HTML_FRAME_STYLE +
|
| 156 |
+
(documentData.discourse ? readerContext.DISCOURSE_HTML_STYLE : "") +
|
| 157 |
+
(documentData.spreadsheet ? readerContext.SPREADSHEET_HTML_STYLE : "");
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
async function waitForSpreadsheetImages(frame) {
|
| 161 |
+
if (frame.contentDocument)
|
| 162 |
+
await readerContext.settleReaderImages(frame.contentDocument, 30000);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
async function renderHtml(prepared) {
|
| 166 |
+
const bytes = await readerContext.readPreparedBytes(prepared.response, prepared.engine);
|
| 167 |
+
const text = new TextDecoder(readerContext.detectHtmlEncoding(bytes, readerContext.documentState.title)).decode(bytes);
|
| 168 |
+
readerContext.assertReaderActive();
|
| 169 |
+
const documentData = readerContext.prepareHtmlDocument(text);
|
| 170 |
+
const frame = document.createElement("iframe");
|
| 171 |
+
readerContext.htmlFrame = frame;
|
| 172 |
+
frame.className = "html-frame";
|
| 173 |
+
readerContext.content.classList.toggle("reader-content--spreadsheet", documentData.spreadsheet);
|
| 174 |
+
if (documentData.spreadsheet) frame.classList.add("spreadsheet-html-frame");
|
| 175 |
+
frame.title = `${readerContext.documentState.title} 正文`;
|
| 176 |
+
frame.setAttribute("sandbox", "allow-same-origin");
|
| 177 |
+
frame.setAttribute("referrerpolicy", "no-referrer");
|
| 178 |
+
frame.style.colorScheme = "only light";
|
| 179 |
+
const frameLoaded = new Promise((resolve, reject) => {
|
| 180 |
+
let untrack = () => {}, frameTimer = 0, deadline = 0, started = false;
|
| 181 |
+
const cleanup = () => {
|
| 182 |
+
clearTimeout(frameTimer);
|
| 183 |
+
clearTimeout(deadline);
|
| 184 |
+
frame.removeEventListener("load", loaded);
|
| 185 |
+
untrack();
|
| 186 |
+
};
|
| 187 |
+
const loaded = async () => {
|
| 188 |
+
if (started || frame.contentDocument?.URL !== "about:srcdoc" || frame.contentDocument.readyState === "loading") return;
|
| 189 |
+
started = true;
|
| 190 |
+
cleanup();
|
| 191 |
+
try {
|
| 192 |
+
readerContext.assertReaderActive();
|
| 193 |
+
readerContext.repairHtmlContrast(frame);
|
| 194 |
+
frame.contentDocument.documentElement.style.zoom = String(readerContext.documentState.zoom);
|
| 195 |
+
frame.contentWindow.addEventListener("scroll", readerContext.handleReaderPositionChange, {
|
| 196 |
+
passive: true
|
| 197 |
+
});
|
| 198 |
+
frame.contentDocument.addEventListener("keydown", readerContext.handleReaderKeydown);
|
| 199 |
+
readerContext.installReaderTapSelectionClear(frame.contentDocument.body, frame.contentDocument);
|
| 200 |
+
for (const type of ["wheel", "touchstart", "pointerdown"])
|
| 201 |
+
frame.contentDocument.addEventListener(type, readerContext.beginReaderNavigation, { passive: true });
|
| 202 |
+
readerContext.setToc(readerContext.headingTocEntries(frame.contentDocument));
|
| 203 |
+
if (documentData.spreadsheet) await readerContext.waitForSpreadsheetImages(frame);
|
| 204 |
+
readerContext.assertReaderActive();
|
| 205 |
+
resolve();
|
| 206 |
+
} catch (error) {
|
| 207 |
+
reject(error);
|
| 208 |
+
}
|
| 209 |
+
};
|
| 210 |
+
untrack = readerContext.trackReaderResource(() => {
|
| 211 |
+
cleanup();
|
| 212 |
+
reject(readerContext.readerAbortError());
|
| 213 |
+
});
|
| 214 |
+
const poll = () => {
|
| 215 |
+
if (started) return;
|
| 216 |
+
loaded();
|
| 217 |
+
if (!started) frameTimer = setTimeout(poll, 16);
|
| 218 |
+
};
|
| 219 |
+
frameTimer = setTimeout(poll, 0);
|
| 220 |
+
deadline = setTimeout(() => { cleanup(); reject(new Error("HTML document initialization timeout")); }, 20000);
|
| 221 |
+
frame.addEventListener("load", loaded);
|
| 222 |
+
});
|
| 223 |
+
frame.srcdoc = readerContext.buildHtmlFrameSource(documentData);
|
| 224 |
+
readerContext.content.appendChild(frame);
|
| 225 |
+
await frameLoaded;
|
| 226 |
+
readerContext.markReaderContentReady();
|
| 227 |
+
readerContext.status.textContent = documentData.spreadsheet ? "表格" : "HTML";
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
function detectHtmlEncoding(bytes, hint = "") {
|
| 231 |
+
const probe = String.fromCharCode(...bytes.subarray(0, 8192));
|
| 232 |
+
const match = probe.match(/charset\s*=\s*["']?\s*([a-z0-9._:-]+)/i);
|
| 233 |
+
if (match) {
|
| 234 |
+
const label =
|
| 235 |
+
{ gb2312: "gb18030", "gb-2312": "gb18030", gbk: "gb18030", "x-gbk": "gb18030" }[
|
| 236 |
+
match[1].toLowerCase()
|
| 237 |
+
] || match[1];
|
| 238 |
+
try {
|
| 239 |
+
new TextDecoder(label);
|
| 240 |
+
return label;
|
| 241 |
+
} catch (_) {}
|
| 242 |
+
}
|
| 243 |
+
return readerContext.detectTextEncoding(bytes, hint);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
function repairHtmlContrast(frame) {
|
| 247 |
+
const doc = frame.contentDocument;
|
| 248 |
+
if (!doc || !doc.body) return;
|
| 249 |
+
const parseColor = (value) => {
|
| 250 |
+
const parts = String(value).match(/[\d.]+/g);
|
| 251 |
+
return parts && parts.length >= 3
|
| 252 |
+
? [
|
| 253 |
+
Number(parts[0]),
|
| 254 |
+
Number(parts[1]),
|
| 255 |
+
Number(parts[2]),
|
| 256 |
+
parts[3] === undefined ? 1 : Number(parts[3])
|
| 257 |
+
]
|
| 258 |
+
: null;
|
| 259 |
+
};
|
| 260 |
+
const luminance = (color) => {
|
| 261 |
+
const channels = color.slice(0, 3).map((value) => {
|
| 262 |
+
const normalized = value / 255;
|
| 263 |
+
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
| 264 |
+
});
|
| 265 |
+
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
| 266 |
+
};
|
| 267 |
+
const backgrounds = new Map();
|
| 268 |
+
const background = (element, style) => {
|
| 269 |
+
const pending = [];
|
| 270 |
+
let light = 1;
|
| 271 |
+
for (let current = element; current; current = current.parentElement) {
|
| 272 |
+
if (backgrounds.has(current)) {
|
| 273 |
+
light = backgrounds.get(current);
|
| 274 |
+
break;
|
| 275 |
+
}
|
| 276 |
+
pending.push(current);
|
| 277 |
+
const color = parseColor((current === element ? style :
|
| 278 |
+
frame.contentWindow.getComputedStyle(current)).backgroundColor);
|
| 279 |
+
if (color && color[3] > 0.1) {
|
| 280 |
+
light = luminance(color);
|
| 281 |
+
break;
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
for (const current of pending) backgrounds.set(current, light);
|
| 285 |
+
return light;
|
| 286 |
+
};
|
| 287 |
+
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
| 288 |
+
const elements = new Set();
|
| 289 |
+
while (walker.nextNode() && elements.size < 10000)
|
| 290 |
+
if (walker.currentNode.data.trim()) elements.add(walker.currentNode.parentElement);
|
| 291 |
+
for (const element of elements) {
|
| 292 |
+
const style = frame.contentWindow.getComputedStyle(element);
|
| 293 |
+
const foreground = parseColor(style.color);
|
| 294 |
+
const dark = background(element, style);
|
| 295 |
+
if (!foreground) continue;
|
| 296 |
+
const light = luminance(foreground);
|
| 297 |
+
const contrast = (Math.max(light, dark) + 0.05) / (Math.min(light, dark) + 0.05);
|
| 298 |
+
if (contrast < 3) {
|
| 299 |
+
element.style.setProperty("color", dark > 0.45 ? "#111" : "#f5f5f5", "important");
|
| 300 |
+
// Inherited color and currentColor backgrounds can change after a repair.
|
| 301 |
+
backgrounds.clear();
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
return {
|
| 307 |
+
normalizeEpubDocument,
|
| 308 |
+
sanitizeReaderDocument,
|
| 309 |
+
sanitizeEpubDocument,
|
| 310 |
+
parseChapterDocument,
|
| 311 |
+
epubContentBody,
|
| 312 |
+
renderText,
|
| 313 |
+
sanitizeOfflineHtml,
|
| 314 |
+
buildDiscourseDocument,
|
| 315 |
+
prepareHtmlDocument,
|
| 316 |
+
buildHtmlFrameSource,
|
| 317 |
+
waitForSpreadsheetImages,
|
| 318 |
+
renderHtml,
|
| 319 |
+
detectHtmlEncoding,
|
| 320 |
+
repairHtmlContrast
|
| 321 |
+
};
|
| 322 |
+
}
|
static/reader/formats/reader-image-loader.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function createReaderImageLoader({assertActive, abortError, trackResource, request, readBytes, timeoutMs}) {
|
| 2 |
+
function wait(image, target, timeoutMs, signal) {
|
| 3 |
+
assertActive();
|
| 4 |
+
return new Promise((resolve, reject) => {
|
| 5 |
+
let settled = false, timeout = 0, untrack = () => {};
|
| 6 |
+
const abort = () => finish(abortError());
|
| 7 |
+
const finish = (error) => {
|
| 8 |
+
if (settled) return;
|
| 9 |
+
settled = true;
|
| 10 |
+
clearTimeout(timeout);
|
| 11 |
+
untrack();
|
| 12 |
+
signal?.removeEventListener("abort", abort);
|
| 13 |
+
image.onload = image.onerror = null;
|
| 14 |
+
if (error) {
|
| 15 |
+
image.removeAttribute("src");
|
| 16 |
+
reject(error);
|
| 17 |
+
} else resolve(image);
|
| 18 |
+
};
|
| 19 |
+
untrack = trackResource(() => finish(abortError()));
|
| 20 |
+
signal?.addEventListener("abort", abort, { once: true });
|
| 21 |
+
if (signal?.aborted) abort();
|
| 22 |
+
if (settled) return;
|
| 23 |
+
timeout = setTimeout(() => finish(new DOMException("Reader image stalled", "TimeoutError")), timeoutMs);
|
| 24 |
+
image.onload = () => finish();
|
| 25 |
+
image.onerror = () => finish(new Error("image load failed"));
|
| 26 |
+
if (image.src !== new URL(target, location.href).href) image.src = target;
|
| 27 |
+
if (image.complete && image.naturalWidth) finish();
|
| 28 |
+
});
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
async function load(image, target, signal, deadline = performance.now() + timeoutMs) {
|
| 32 |
+
const remaining = () => Math.max(1, deadline - performance.now());
|
| 33 |
+
const network = /^https?:$/.test(new URL(target, location.href).protocol);
|
| 34 |
+
try {
|
| 35 |
+
return await wait(image, target, Math.min(network ? 15000 : timeoutMs, remaining()), signal);
|
| 36 |
+
} catch (error) {
|
| 37 |
+
assertActive();
|
| 38 |
+
signal?.throwIfAborted();
|
| 39 |
+
if (!network || error?.name !== "TimeoutError" || !navigator.onLine) throw error;
|
| 40 |
+
}
|
| 41 |
+
const response = await request(target, remaining(),
|
| 42 |
+
{ signal, priority: image.fetchPriority || "auto" });
|
| 43 |
+
if (!response.ok) {
|
| 44 |
+
await response.body?.cancel();
|
| 45 |
+
throw Object.assign(new Error(`HTTP ${response.status}`), {status: response.status});
|
| 46 |
+
}
|
| 47 |
+
const bytes = await readBytes(response);
|
| 48 |
+
assertActive();
|
| 49 |
+
signal?.throwIfAborted();
|
| 50 |
+
const blobUrl = URL.createObjectURL(new Blob([bytes], {type: response.headers.get("Content-Type") || ""}));
|
| 51 |
+
try {
|
| 52 |
+
return await wait(image, blobUrl, Math.min(20000, remaining()), signal);
|
| 53 |
+
} finally {
|
| 54 |
+
URL.revokeObjectURL(blobUrl);
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return Object.freeze({load});
|
| 59 |
+
}
|
static/reader/formats/reader-media-format.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function createReaderMediaFormat(readerContext) {
|
| 2 |
+
function formatMediaTime(seconds) {
|
| 3 |
+
if (!Number.isFinite(seconds) || seconds < 0) return "00:00";
|
| 4 |
+
const total = Math.floor(seconds),
|
| 5 |
+
hours = Math.floor(total / 3600),
|
| 6 |
+
minutes = Math.floor((total % 3600) / 60),
|
| 7 |
+
secs = total % 60;
|
| 8 |
+
return `${hours ? `${String(hours).padStart(2, "0")}:` : ""}${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function renderImageDocument(image) {
|
| 12 |
+
readerContext.assertReaderActive();
|
| 13 |
+
readerContext.content.appendChild(image);
|
| 14 |
+
readerContext.markReaderContentReady();
|
| 15 |
+
readerContext.status.textContent = "图片";
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function renderMedia(mode) {
|
| 19 |
+
readerContext.assertReaderActive();
|
| 20 |
+
const media = document.createElement(mode);
|
| 21 |
+
media.className = mode === "audio" ? "reader-audio" : "reader-video";
|
| 22 |
+
media.controls = true;
|
| 23 |
+
media.setAttribute("aria-label", `${readerContext.documentState.title} ${mode === "audio" ? "音频" : "视频"}`);
|
| 24 |
+
media.preload = "metadata";
|
| 25 |
+
if (mode === "video") media.playsInline = true;
|
| 26 |
+
const refresh = () => {
|
| 27 |
+
if (readerContext.readerAbortController.signal.aborted) return;
|
| 28 |
+
readerContext.mediaPanel.querySelector(".media-panel-time").textContent =
|
| 29 |
+
`${readerContext.formatMediaTime(media.currentTime)} / ${readerContext.formatMediaTime(media.duration)}`;
|
| 30 |
+
readerContext.scheduleMarkerSync();
|
| 31 |
+
};
|
| 32 |
+
for (const type of ["timeupdate", "durationchange", "loadedmetadata"])
|
| 33 |
+
media.addEventListener(type, refresh, { passive: true });
|
| 34 |
+
for (const type of ["pause", "ended", "seeked"])
|
| 35 |
+
media.addEventListener(type, () => {
|
| 36 |
+
refresh();
|
| 37 |
+
readerContext.saveProgress();
|
| 38 |
+
});
|
| 39 |
+
readerContext.mediaElement = media;
|
| 40 |
+
let sourceRetried = false;
|
| 41 |
+
const originalMode = VoiceOfMLReader.capability(readerContext.resolvedReaderData?.original_extension || readerContext.extension).mode;
|
| 42 |
+
const retryUrl = originalMode === mode && readerContext.validSource(readerContext.downloadUrl) ? readerContext.downloadUrl : readerContext.sourceUrl;
|
| 43 |
+
media.addEventListener("error", () => {
|
| 44 |
+
if (readerContext.readerAbortController.signal.aborted) return;
|
| 45 |
+
if (!sourceRetried && retryUrl !== media.src && readerContext.validSource(retryUrl)) {
|
| 46 |
+
sourceRetried = true;
|
| 47 |
+
media._readerSourceRetry = true;
|
| 48 |
+
media.src = retryUrl;
|
| 49 |
+
media.load();
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
media._readerSourceRetry = false;
|
| 53 |
+
readerContext.fail("媒体加载失败,请检查网络后重试,或下载原文件。", "READER_MEDIA");
|
| 54 |
+
});
|
| 55 |
+
media.src = readerContext.contentUrl;
|
| 56 |
+
readerContext.content.appendChild(media);
|
| 57 |
+
readerContext.markReaderContentReady();
|
| 58 |
+
readerContext.status.textContent = mode === "audio" ? "音频" : "视频";
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
async function loadSwfDocument() {
|
| 62 |
+
await readerContext.loadScript(readerContext.RUFFLE_URL);
|
| 63 |
+
const response = await readerContext.fetchReaderResponse();
|
| 64 |
+
return new Uint8Array(await response.arrayBuffer());
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
async function renderSwf(data) {
|
| 68 |
+
readerContext.assertReaderActive();
|
| 69 |
+
const factory = window.RufflePlayer?.newest?.();
|
| 70 |
+
if (!factory) throw Object.assign(new Error("Ruffle 未加载"), { code: "READER_SWF_ENGINE" });
|
| 71 |
+
const player = factory.createPlayer();
|
| 72 |
+
player.className = "reader-swf";
|
| 73 |
+
player.setAttribute("aria-label", `${readerContext.documentState.title} Flash 内容`);
|
| 74 |
+
player.style.width = "100%";
|
| 75 |
+
player.style.minHeight = "360px";
|
| 76 |
+
readerContext.content.appendChild(player);
|
| 77 |
+
readerContext.swfPlayer = player;
|
| 78 |
+
await player.ruffle().load({ data });
|
| 79 |
+
readerContext.markReaderContentReady();
|
| 80 |
+
readerContext.status.textContent = "Flash 内容";
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
return {
|
| 84 |
+
formatMediaTime,
|
| 85 |
+
renderImageDocument,
|
| 86 |
+
renderMedia,
|
| 87 |
+
loadSwfDocument,
|
| 88 |
+
renderSwf
|
| 89 |
+
};
|
| 90 |
+
}
|
static/{reader-section-virtualizer.js → reader/formats/reader-section-virtualizer.js}
RENAMED
|
File without changes
|
static/reader/formats/reader-text-format.mjs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function createReaderTextFormat(readerContext) {
|
| 2 |
+
async function renderPlainText(response) {
|
| 3 |
+
readerContext.assertReaderActive();
|
| 4 |
+
const pre = document.createElement("div");
|
| 5 |
+
pre.className = "reader-text";
|
| 6 |
+
readerContext.content.appendChild(pre);
|
| 7 |
+
let block = null, blockLength = 0;
|
| 8 |
+
const appendDecoded = async value => {
|
| 9 |
+
const text = value.replace(/\ufffd/g, "");
|
| 10 |
+
let offset = 0, deadline = performance.now() + 8;
|
| 11 |
+
while (offset < text.length) {
|
| 12 |
+
if (!block) {
|
| 13 |
+
block = document.createElement("pre");
|
| 14 |
+
block.className = "reader-text-block";
|
| 15 |
+
pre.appendChild(block);
|
| 16 |
+
}
|
| 17 |
+
let end = Math.min(text.length, offset + 32768 - blockLength);
|
| 18 |
+
if (end < text.length) {
|
| 19 |
+
const newline = text.lastIndexOf("\n", end - 1);
|
| 20 |
+
if (newline >= offset) end = newline + 1;
|
| 21 |
+
const previous = text.charCodeAt(end - 1), next = text.charCodeAt(end);
|
| 22 |
+
if ((previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) ||
|
| 23 |
+
(previous === 13 && next === 10)) end--;
|
| 24 |
+
}
|
| 25 |
+
if (end === offset) { block = null; blockLength = 0; continue; }
|
| 26 |
+
const tail = block.lastChild;
|
| 27 |
+
if (tail?.nodeType === Node.TEXT_NODE) tail.appendData(text.slice(offset, end));
|
| 28 |
+
else block.appendChild(document.createTextNode(text.slice(offset, end)));
|
| 29 |
+
if (text.length) readerContext.markReaderContentReady();
|
| 30 |
+
blockLength += end - offset;
|
| 31 |
+
offset = end;
|
| 32 |
+
if (offset < text.length || blockLength >= 32768) { block = null; blockLength = 0; }
|
| 33 |
+
if (offset < text.length && performance.now() >= deadline) {
|
| 34 |
+
await readerContext.waitForReader();
|
| 35 |
+
readerContext.assertReaderActive();
|
| 36 |
+
deadline = performance.now() + 8;
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
const limit = VoiceOfMLReaderSecurity.LIMITS.documentBytes;
|
| 41 |
+
VoiceOfMLReaderSecurity.assertResponseSize(response, limit);
|
| 42 |
+
if (!response.body?.getReader) {
|
| 43 |
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
| 44 |
+
if (bytes.byteLength > limit) {
|
| 45 |
+
const error = new Error("READER_RESOURCE_LIMIT");
|
| 46 |
+
error.code = "READER_RESOURCE_LIMIT";
|
| 47 |
+
throw error;
|
| 48 |
+
}
|
| 49 |
+
readerContext.assertReaderActive();
|
| 50 |
+
await appendDecoded(new TextDecoder(readerContext.detectTextEncoding(bytes, readerContext.documentState.title)).decode(bytes));
|
| 51 |
+
return;
|
| 52 |
+
}
|
| 53 |
+
const reader = response.body.getReader(),
|
| 54 |
+
sampleChunks = [],
|
| 55 |
+
previewDecoder = new TextDecoder("utf-8");
|
| 56 |
+
const untrack = readerContext.trackReaderResource(() => {
|
| 57 |
+
reader.cancel(readerContext.readerAbortError()).catch(() => {});
|
| 58 |
+
});
|
| 59 |
+
let length = 0,
|
| 60 |
+
sampleLength = 0,
|
| 61 |
+
displayedSampleSize = 0,
|
| 62 |
+
previewChunkIndex = 0,
|
| 63 |
+
previewChunkOffset = 0,
|
| 64 |
+
decoder = null,
|
| 65 |
+
asciiPreviewPossible = true,
|
| 66 |
+
asciiPrefixLength = 0;
|
| 67 |
+
const startDecoder = async () => {
|
| 68 |
+
const sample = new Uint8Array(sampleLength);
|
| 69 |
+
let offset = 0;
|
| 70 |
+
for (const chunk of sampleChunks) {
|
| 71 |
+
sample.set(chunk, offset);
|
| 72 |
+
offset += chunk.byteLength;
|
| 73 |
+
}
|
| 74 |
+
decoder = new TextDecoder(readerContext.detectTextEncoding(sample, readerContext.documentState.title));
|
| 75 |
+
await appendDecoded(decoder.decode(sample.subarray(displayedSampleSize), { stream: true }));
|
| 76 |
+
sampleChunks.length = 0;
|
| 77 |
+
};
|
| 78 |
+
try {
|
| 79 |
+
while (true) {
|
| 80 |
+
const { value, done } = await readerContext.awaitReader(reader.read());
|
| 81 |
+
readerContext.assertReaderActive();
|
| 82 |
+
if (done) break;
|
| 83 |
+
if (!value?.byteLength) continue;
|
| 84 |
+
length += value.byteLength;
|
| 85 |
+
if (length > limit) {
|
| 86 |
+
const error = new Error("READER_RESOURCE_LIMIT");
|
| 87 |
+
error.code = "READER_RESOURCE_LIMIT";
|
| 88 |
+
throw error;
|
| 89 |
+
}
|
| 90 |
+
if (!decoder) {
|
| 91 |
+
sampleChunks.push(value);
|
| 92 |
+
sampleLength += value.byteLength;
|
| 93 |
+
if (asciiPreviewPossible) {
|
| 94 |
+
let asciiLength = 0;
|
| 95 |
+
while (asciiLength < value.length && value[asciiLength] > 0 && value[asciiLength] < 128)
|
| 96 |
+
asciiLength++;
|
| 97 |
+
asciiPrefixLength += asciiLength;
|
| 98 |
+
if (value[asciiLength] === 0) {
|
| 99 |
+
// A split UTF-16 prefix must not leave an ASCII preview behind.
|
| 100 |
+
pre.textContent = "";
|
| 101 |
+
block = null;
|
| 102 |
+
blockLength = 0;
|
| 103 |
+
displayedSampleSize = 0;
|
| 104 |
+
asciiPrefixLength = 0;
|
| 105 |
+
asciiPreviewPossible = false;
|
| 106 |
+
} else if (asciiLength < value.length) asciiPreviewPossible = false;
|
| 107 |
+
}
|
| 108 |
+
if (asciiPrefixLength >= 4 && asciiPrefixLength > displayedSampleSize) {
|
| 109 |
+
// The full sample remains for encoding detection; preview only its
|
| 110 |
+
// unseen ASCII prefix instead of revisiting all previous chunks.
|
| 111 |
+
while (displayedSampleSize < asciiPrefixLength) {
|
| 112 |
+
const chunk = sampleChunks[previewChunkIndex];
|
| 113 |
+
const end = Math.min(chunk.length, previewChunkOffset + asciiPrefixLength - displayedSampleSize);
|
| 114 |
+
await appendDecoded(previewDecoder.decode(chunk.subarray(previewChunkOffset, end)));
|
| 115 |
+
displayedSampleSize += end - previewChunkOffset;
|
| 116 |
+
previewChunkOffset = end;
|
| 117 |
+
if (end === chunk.length) {
|
| 118 |
+
previewChunkIndex++;
|
| 119 |
+
previewChunkOffset = 0;
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
if (sampleLength < 65540) continue;
|
| 124 |
+
await startDecoder();
|
| 125 |
+
} else {
|
| 126 |
+
await appendDecoded(decoder.decode(value, { stream: true }));
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
if (!decoder) await startDecoder();
|
| 130 |
+
await appendDecoded(decoder.decode());
|
| 131 |
+
} catch (reason) {
|
| 132 |
+
try {
|
| 133 |
+
await reader.cancel(reason);
|
| 134 |
+
} catch (_) {}
|
| 135 |
+
throw reason;
|
| 136 |
+
} finally {
|
| 137 |
+
untrack();
|
| 138 |
+
reader.releaseLock();
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function detectTextEncoding(bytes, hint = "") {
|
| 143 |
+
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return "utf-8";
|
| 144 |
+
if (bytes[0] === 0xff && bytes[1] === 0xfe) return "utf-16le";
|
| 145 |
+
if (bytes[0] === 0xfe && bytes[1] === 0xff) return "utf-16be";
|
| 146 |
+
const evenNulls = bytes.filter((value, index) => !value && index % 2 === 0).length;
|
| 147 |
+
const oddNulls = bytes.filter((value, index) => !value && index % 2 === 1).length;
|
| 148 |
+
if (oddNulls > bytes.length / 8 && oddNulls > evenNulls * 4) return "utf-16le";
|
| 149 |
+
if (evenNulls > bytes.length / 8 && evenNulls > oddNulls * 4) return "utf-16be";
|
| 150 |
+
try {
|
| 151 |
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true });
|
| 152 |
+
return "utf-8";
|
| 153 |
+
} catch (_) {}
|
| 154 |
+
if (/[\u0400-\u04ff]/.test(hint)) return "windows-1251";
|
| 155 |
+
const candidates = /[\u3400-\u9fff]/.test(hint)
|
| 156 |
+
? ["gb18030", "big5"]
|
| 157 |
+
: ["gb18030", "big5", "windows-1251", "windows-1252"];
|
| 158 |
+
let best = "gb18030",
|
| 159 |
+
bestScore = -Infinity;
|
| 160 |
+
for (const encoding of candidates) {
|
| 161 |
+
try {
|
| 162 |
+
const text = new TextDecoder(encoding).decode(bytes);
|
| 163 |
+
const controls = (text.match(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g) || []).length;
|
| 164 |
+
const replacements = (text.match(/\ufffd/g) || []).length;
|
| 165 |
+
const cjk = (text.match(/[\u3400-\u9fff]/g) || []).length;
|
| 166 |
+
const cyrillic = (text.match(/[\u0400-\u04ff]/g) || []).length;
|
| 167 |
+
const commonCjk = (
|
| 168 |
+
text.match(
|
| 169 |
+
/[的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经之进着等部家自理起现实都体制当本性应开合因由然前外政社义事相全与关各重新内正反明原利质向道命此变结解问意建公系军情者立代通题党程展料员革文总品活长求老基资级图统知组别期论运农指区战任处理世]/g
|
| 170 |
+
) || []
|
| 171 |
+
).length;
|
| 172 |
+
const score = Math.max(cjk + commonCjk * 5, cyrillic) - controls * 20 - replacements * 40;
|
| 173 |
+
if (score > bestScore) {
|
| 174 |
+
best = encoding;
|
| 175 |
+
bestScore = score;
|
| 176 |
+
}
|
| 177 |
+
} catch (_) {}
|
| 178 |
+
}
|
| 179 |
+
return best;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
function renderTextDocument(prepared) {
|
| 183 |
+
return readerContext.renderText(false, prepared);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
function renderMarkdownDocument(prepared) {
|
| 187 |
+
return readerContext.renderText(true, prepared);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
return {
|
| 191 |
+
renderPlainText,
|
| 192 |
+
detectTextEncoding,
|
| 193 |
+
renderTextDocument,
|
| 194 |
+
renderMarkdownDocument
|
| 195 |
+
};
|
| 196 |
+
}
|
static/{pdf-worker-wrapper.mjs → reader/pdf/pdf-worker-wrapper.mjs}
RENAMED
|
@@ -19,12 +19,12 @@ if (!Math.sumPrecise) {
|
|
| 19 |
};
|
| 20 |
}
|
| 21 |
|
| 22 |
-
import "./reader-resources.js";
|
| 23 |
const pendingMessages = [];
|
| 24 |
const bufferMessage = event => pendingMessages.push(event);
|
| 25 |
self.addEventListener("message", bufferMessage);
|
| 26 |
|
| 27 |
-
import(VoiceOfMLReaderResources.vendorUrl("pdfWorker", "./")).then(() => {
|
| 28 |
self.removeEventListener("message", bufferMessage);
|
| 29 |
for (const event of pendingMessages) self.dispatchEvent(new MessageEvent("message", { data: event.data }));
|
| 30 |
});
|
|
|
|
| 19 |
};
|
| 20 |
}
|
| 21 |
|
| 22 |
+
import "../../reader-resources.js";
|
| 23 |
const pendingMessages = [];
|
| 24 |
const bufferMessage = event => pendingMessages.push(event);
|
| 25 |
self.addEventListener("message", bufferMessage);
|
| 26 |
|
| 27 |
+
import(VoiceOfMLReaderResources.vendorUrl("pdfWorker", "../../")).then(() => {
|
| 28 |
self.removeEventListener("message", bufferMessage);
|
| 29 |
for (const event of pendingMessages) self.dispatchEvent(new MessageEvent("message", { data: event.data }));
|
| 30 |
});
|
static/{reader-engine-preload.js → reader/pdf/reader-engine-preload.js}
RENAMED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
let pdf = params.get("ext")?.toLowerCase() === "pdf";
|
| 5 |
try { pdf ||= new URL(params.get("url")).pathname.toLowerCase().endsWith(".pdf"); } catch (_) {}
|
| 6 |
if (!pdf) return;
|
| 7 |
-
const base = new URL(".", document.currentScript.src).href;
|
| 8 |
for (const name of ["pdf", "pdfWorker"]) {
|
| 9 |
const link = document.createElement("link");
|
| 10 |
link.rel = "modulepreload";
|
|
|
|
| 4 |
let pdf = params.get("ext")?.toLowerCase() === "pdf";
|
| 5 |
try { pdf ||= new URL(params.get("url")).pathname.toLowerCase().endsWith(".pdf"); } catch (_) {}
|
| 6 |
if (!pdf) return;
|
| 7 |
+
const base = new URL("../../", document.currentScript.src).href;
|
| 8 |
for (const name of ["pdf", "pdfWorker"]) {
|
| 9 |
const link = document.createElement("link");
|
| 10 |
link.rel = "modulepreload";
|