vomebook commited on
Commit
4ef7354
·
1 Parent(s): 30d788e

refactor: organize project layout and Reader responsibilities

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .prettierrc.json +2 -1
  2. Dockerfile +11 -9
  3. app.py +0 -0
  4. backend/__init__.py +1 -0
  5. backend/app.py +0 -0
  6. data_formats.py → backend/data_formats.py +0 -0
  7. search_index_native.c → backend/native/search_index_native.c +0 -0
  8. proxy_streams.py → backend/proxy_streams.py +0 -0
  9. reader_assets.py → backend/reader_assets.py +1 -1
  10. reader_v3_catalog.py → backend/reader_v3_catalog.py +1 -1
  11. record_metadata.py → backend/record_metadata.py +0 -0
  12. search_execution.py → backend/search_execution.py +0 -0
  13. search_indexes.py → backend/search_indexes.py +0 -0
  14. upstream_http.py → backend/upstream_http.py +0 -0
  15. scripts/{benchmark_reader_open.py → benchmarks/benchmark_reader_open.py} +0 -0
  16. scripts/{benchmark_reader_stages.py → benchmarks/benchmark_reader_stages.py} +0 -0
  17. scripts/{reader-opening-media-samples.json → benchmarks/reader-opening-media-samples.json} +0 -0
  18. scripts/{reader-opening-samples.json → benchmarks/reader-opening-samples.json} +0 -0
  19. scripts/{build_search_index_native.py → build/build_search_index_native.py} +1 -1
  20. scripts/{build_static_assets.mjs → build/build_static_assets.mjs} +12 -4
  21. scripts/{check_reader_release.mjs → build/check_reader_release.mjs} +34 -26
  22. scripts/{compose_app.mjs → build/compose_app.mjs} +3 -3
  23. scripts/{copy_reader_vendor.mjs → build/copy_reader_vendor.mjs} +0 -0
  24. static/index.html +4 -4
  25. static/reader-resources.js +57 -34
  26. static/reader.html +4 -4
  27. static/reader.js +0 -0
  28. static/reader/core/reader-bindings.mjs +847 -0
  29. static/{reader-contract.js → reader/core/reader-contract.js} +0 -0
  30. static/reader/core/reader-diagnostics.mjs +22 -0
  31. static/reader/core/reader-document-loader.mjs +164 -0
  32. static/reader/core/reader-lifecycle.mjs +170 -0
  33. static/{reader-request-manager.js → reader/core/reader-request-manager.js} +0 -0
  34. static/{reader-runtime.js → reader/core/reader-runtime.js} +0 -0
  35. static/{reader-security.js → reader/core/reader-security.js} +0 -0
  36. static/reader/core/reader-source.mjs +335 -0
  37. static/{reader-store.js → reader/core/reader-store.js} +0 -0
  38. static/reader/formats/reader-chapter-format.mjs +450 -0
  39. static/{reader-chapter-repository.js → reader/formats/reader-chapter-repository.js} +0 -0
  40. static/reader/formats/reader-docx-format.mjs +129 -0
  41. static/reader/formats/reader-foliate-format.mjs +637 -0
  42. static/{reader-format-adapters.js → reader/formats/reader-format-adapters.js} +0 -0
  43. static/reader/formats/reader-format-controller.mjs +280 -0
  44. static/reader/formats/reader-html-format.mjs +322 -0
  45. static/reader/formats/reader-image-loader.mjs +59 -0
  46. static/reader/formats/reader-media-format.mjs +90 -0
  47. static/{reader-section-virtualizer.js → reader/formats/reader-section-virtualizer.js} +0 -0
  48. static/reader/formats/reader-text-format.mjs +196 -0
  49. static/{pdf-worker-wrapper.mjs → reader/pdf/pdf-worker-wrapper.mjs} +2 -2
  50. 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
- "scripts/check_reader_release.mjs"
 
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
- esbuild "static/$file" --minify --target=es2020 --charset=utf8 --legal-comments=none --outfile="/out/$file" || exit 1; \
 
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 data_formats.py proxy_streams.py record_metadata.py upstream_http.py search_execution.py search_indexes.py reader_assets.py reader_v3_catalog.py .
 
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[1] / "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",
 
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 = fs.readFileSync(path.join(root, "static/reader.js"), "utf8");
43
- for (const [, dependency] of reader.matchAll(/(?:\/static\/)([^/"']+\.(?:js|mjs))/g)) {
 
44
  assert(
45
- resources.readerFiles.includes(dependency),
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 extension = path.extname(filename), stem = filename.slice(0, -extension.length);
122
- const matches = fs.readdirSync(output).filter(file => file.startsWith(stem + ".") &&
 
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, "index.html")) ? "github" : "hf";
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
- "pdf-worker-wrapper.mjs"
 
 
 
 
 
 
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\/)([^"/]+)"\s*>\s*<\/script>\s*/g,
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-pdf-text.js",
106
- "reader-v3.mjs",
107
- "reader-book-text.js",
108
- "reader-pdf-book-search-worker.mjs",
109
- "reader-pdf-book-search.mjs",
110
- "pdf-worker-wrapper.mjs",
111
- "reader-pdf-network.mjs",
112
- "reader-pdf-text-store.mjs",
 
113
  "reader.css",
 
 
114
  "reader.js"
115
  ]);
116
  const lazyFiles = new Set([
117
- "reader-pdf-text-store.mjs",
118
- "reader-chapter-search-worker.mjs",
119
- "reader-chapter-search.mjs",
120
- "reader-pdf-book-search-worker.mjs",
121
- "reader-pdf-book-search.mjs",
122
- "pdf-worker-wrapper.mjs"
 
 
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";