Search / static /reader.js
vomebook
fix: remove redundant PDF loading overlays
9ed8d43
Raw History Blame Contribute Delete
279 kB
import "/static/reader-resources.js";
import "/static/reader-contract.js";
import "/static/reader-request-manager.js";
import "/static/reader-chapter-repository.js";
import "/static/reader-scroll-anchor.js";
import "/static/reader-section-virtualizer.js";
import "/static/reader-runtime.js";
import "/static/reader-format-adapters.js";
import { populatePdfTextLayer, populateOcrTextLayer, pdfTextContent, pdfSearchText } from "/static/reader-pdf-text.js";
import { paintTextHit } from "/static/reader-book-text.js";
// Engines and Reader lifecycle.
const PDFJS_URL = VoiceOfMLReaderResources.vendorUrl("pdf", "/static/");
const PDFJS_WORKER_URL = "/static/pdf-worker-wrapper.mjs";
const PDFJS_WASM_URL = "/static/vendor/wasm/";
const PDFJS_CMAP_URL = "/static/vendor/cmaps/";
const PDFJS_STANDARD_FONT_URL = "/static/vendor/standard_fonts/";
const MARKED_URL = VoiceOfMLReaderResources.vendorUrl("marked", "/static/");
const PURIFY_URL = VoiceOfMLReaderResources.vendorUrl("purify", "/static/");
const JSZIP_URL = VoiceOfMLReaderResources.vendorUrl("jszip", "/static/");
const DOCX_PREVIEW_URL = VoiceOfMLReaderResources.vendorUrl("docx", "/static/");
const RUFFLE_URL = VoiceOfMLReaderResources.vendors.ruffle.url;
const READER_PROXY_TIMEOUT_MS = 120000;
const readerRequestManager = VoiceOfMLReaderRequests.createReaderRequestManager();
const readerRuntime = VoiceOfMLReaderRuntime.createReaderRuntime();
const formatAdapters = VoiceOfMLReaderAdapters.createAdapterRegistry();
readerRuntime.track(readerRequestManager);
readerRuntime.track(formatAdapters);
readerRuntime.track(window.__VOICE_PDF_PRELOAD__);
const readerAbortController = new AbortController();
const readerResources = new Set();
function readerAbortError() {
return new DOMException("Reader work cancelled", "AbortError");
}
function assertReaderActive() {
if (readerAbortController.signal.aborted || readerRuntime.state.lifecycle.disposed)
throw readerAbortError();
}
function trackReaderResource(cleanup) {
if (readerAbortController.signal.aborted) cleanup();
else readerResources.add(cleanup);
return () => readerResources.delete(cleanup);
}
function stopReaderWork() {
readerAbortController.abort();
for (const cleanup of readerResources) {
try {
cleanup();
} catch (_) {}
}
readerResources.clear();
}
readerRuntime.track(stopReaderWork);
trackReaderResource(() => window.__VOICE_READER_RESOLVE__?.dispose());
function awaitReader(promise) {
return new Promise((resolve, reject) => {
const signal = readerAbortController.signal,
abort = () => reject(readerAbortError());
Promise.resolve(promise)
.then(resolve, reject)
.finally(() => signal.removeEventListener("abort", abort));
if (signal.aborted) abort();
else signal.addEventListener("abort", abort, { once: true });
});
}
function waitForReader(delay = 0, animationFrame = false) {
return new Promise((resolve, reject) => {
assertReaderActive();
const signal = readerAbortController.signal;
const abort = () => {
if (animationFrame) cancelAnimationFrame(handle);
else clearTimeout(handle);
reject(readerAbortError());
};
const done = () => {
signal.removeEventListener("abort", abort);
resolve();
};
const handle = animationFrame ? requestAnimationFrame(done) : setTimeout(done, delay);
signal.addEventListener("abort", abort, { once: true });
});
}
// Polyfills for vendor engines (pdf.js, foliate): neither helper has a direct
// caller in first-party code, but both are required by pinned vendor bundles.
if (!Map.prototype.getOrInsertComputed) {
Map.prototype.getOrInsertComputed = function (key, callback) {
if (this.has(key)) return this.get(key);
const value = callback(key);
this.set(key, value);
return value;
};
}
if (!Math.sumPrecise) {
Math.sumPrecise = function (values) {
let sum = 0;
let correction = 0;
for (const value of values) {
const next = sum + value;
correction += Math.abs(sum) >= Math.abs(value) ? sum - next + value : value - next + sum;
sum = next;
}
return sum + correction;
};
}
// Source metadata and session navigation.
function readerContentUrl(url) {
return `/api/reader-content?url=${encodeURIComponent(url)}`;
}
function readerDownloadApiUrl(path) {
return path;
}
function readerDownloadUrl(filename, link) {
return readerDownloadApiUrl(`/api/download?file=${encodeURIComponent(filename || "file")}&link=${encodeURIComponent(link || "")}`);
}
async function startReaderDownload(event) {
event.preventDefault();
const button = event.currentTarget;
if (button.hasAttribute("aria-busy")) return;
showDownloadFeedback("正在准备下载…", 0);
const controller = new AbortController();
let timer;
button.setAttribute("aria-busy", "true");
try {
// ID-only links share the Reader's in-flight resolution, not its render phase.
if (!downloadUrl) await readerSourcePromise;
if (!downloadUrl) throw new Error("无法获取原文件地址");
const filename = documentState.title || "file";
const checkUrl = readerDownloadApiUrl(`/api/download/check?link=${encodeURIComponent(downloadUrl)}`);
timer = setTimeout(() => controller.abort(), 8000);
const response = await fetch(checkUrl, { signal: controller.signal, cache: "no-store" });
const result = await response.json().catch(() => null);
if (!response.ok || result?.ok !== true) throw new Error(result?.error || "下载检查失败,请重试");
if (readerLifecycle.disposed) return;
const frame = document.createElement("iframe");
frame.hidden = true;
frame.setAttribute("aria-hidden", "true");
frame.src = readerDownloadUrl(filename, downloadUrl);
document.body.appendChild(frame);
setTimeout(() => frame.remove(), 60000);
showDownloadFeedback("已发起下载");
} catch (error) {
if (!readerLifecycle.disposed) {
showDownloadFeedback(error?.name === "AbortError" ? "下载检查超时,请重试" : (error.message || "下载失败,请重试"));
}
} finally {
clearTimeout(timer);
button.removeAttribute("aria-busy");
}
}
function normalizeSourceUrl(url) {
return url.startsWith("/api/reader-bucket-resource?") ? new URL(url, location.origin).href : url;
}
const params = new URLSearchParams(location.search);
const readerId = params.get("id") || "";
let localReaderData = null;
try {
localReaderData = JSON.parse(sessionStorage.getItem(`reader-source:${readerId}`) || "null");
if (localReaderData) sessionStorage.removeItem(`reader-source:${readerId}`);
} catch (_) {}
let sourceUrl = normalizeSourceUrl(
params.get("url") || (!readerId && localReaderData && localReaderData.url) || ""
);
let contentUrl = readerContentUrl(sourceUrl);
let downloadUrl =
params.get("download") || (localReaderData && localReaderData.download) || sourceUrl;
let extension = (params.get("ext") || "").toLowerCase();
let chapterManifestUrl = params.get("chapter_manifest") || "";
let capability = readerRuntime.negotiate(VoiceOfMLReader.capability(extension));
if (
extension === "pdf" &&
/\/api\/reader-bucket-resource\?/.test(sourceUrl) &&
new URL(sourceUrl, location.href).searchParams.get("path")?.endsWith("page-manifest.json")
) {
extension = "pdf-pages";
capability = readerRuntime.negotiate(VoiceOfMLReader.capability(extension));
}
let resolvedReaderData = localReaderData;
let cachedReaderData = null;
let pdfFirstPagePreload = null;
let pdfFirstPagePreloadPage = 1;
let pdfEnginePreload = null;
let pdfWorker = null;
let releasePdfWorker = null;
if (localReaderData) {
if (localReaderData.title) params.set("title", localReaderData.title);
if (localReaderData.extension) params.set("ext", localReaderData.extension);
if (localReaderData.repo)
params.set("path", [localReaderData.repo, ...(localReaderData.folder || [])].join("/"));
}
try {
const cached = sessionStorage.getItem(`reader-resolve:${readerId}`);
if (cached) {
cachedReaderData = JSON.parse(cached);
resolvedReaderData = cachedReaderData;
sessionStorage.removeItem(`reader-resolve:${readerId}`);
}
} catch (_) {}
const readerLifecycle = readerRuntime.state.lifecycle;
readerRuntime.update("source", {
id: readerId,
url: sourceUrl,
contentUrl,
downloadUrl,
extension,
metadata: resolvedReaderData
});
readerRuntime.events.on("phase", ({ phase }) => {
document.documentElement.dataset.readerPhase = phase;
});
readerRuntime.track(
VoiceOfMLReaderStore.subscribe?.((change) => {
if (!change?.remote || readerLifecycle.disposed) return;
if (
change.type === "history-clear" ||
(change.type === "history-remove" && change.url === sourceUrl)
) {
historySuppressed = true;
readerRuntime.cancel(saveTimer);
saveTimer = saveDeadline = 0;
}
if (
["history", "history-clear", "history-remove"].includes(change.type) &&
!document.querySelector("#history-panel").hidden
)
renderHistory();
if (
["bookmark", "bookmark-remove"].includes(change.type) &&
(panelState.showingAllBookmarks || !change.url || change.url === sourceUrl) &&
!document.querySelector("#bookmarks-panel").hidden
)
renderBookmarks();
})
);
function setReaderPhase(phase) {
return readerRuntime.setPhase(phase);
}
function setReaderStage(stage) {
return readerRuntime.setStage(stage);
}
async function resolveReaderId(id) {
const endpoint = `/api/reader-resolve?id=${encodeURIComponent(id)}`;
for (let attempt = 0; attempt < 2; attempt++) {
const early = window.__VOICE_READER_RESOLVE__;
if (!attempt && early?.id === id) {
window.__VOICE_READER_RESOLVE__ = null;
const untrack = trackReaderResource(early.dispose);
let result;
try { result = await awaitReader(early.promise); }
finally { untrack(); early.dispose(); }
if (result.ok) return result.data;
if (![408, 429, 500, 502, 503, 504].includes(result.status))
throw new Error(`HTTP ${result.status}`);
await waitForReader(350);
continue;
}
const response = await readerRequestManager.request(endpoint, READER_PROXY_TIMEOUT_MS);
if (response.ok) return response.json();
if (![408, 429, 500, 502, 503, 504].includes(response.status) || attempt)
throw new Error(`HTTP ${response.status}`);
await response.body?.cancel();
await waitForReader(350);
}
throw new Error("Reader ID resolution failed");
}
function classifyReaderError(error, fallback = "READER_PARSE") {
if (["READER_ENGINE_NETWORK", "READER_RESTORE"].includes(error?.code)) return error.code;
const value = `${error?.name || ""} ${error?.message || error || ""}`;
if (/AbortError|timeout|network|fetch|HTTP\s*\d+/i.test(value)) return "READER_NETWORK";
if (
/EPUB_INVALID|corrupt|damage|truncated|central directory|end of data|invalid zip/i.test(value)
)
return "READER_CORRUPT";
return fallback;
}
setReaderPhase("startup");
window.fetchFile = async (url) => {
const requestUrl = String(url) === sourceUrl ? contentUrl : url;
const response = await readerRequestManager.request(requestUrl, READER_PROXY_TIMEOUT_MS);
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
const fileName = new URL(url, location.href).pathname.split("/").pop() || "book";
return new File(
[
await VoiceOfMLReaderSecurity.readBytes(
response,
VoiceOfMLReaderSecurity.LIMITS.archiveCompressedBytes
)
],
fileName,
{ type: response.headers.get("content-type") || "application/octet-stream" }
);
};
const content = document.querySelector("#content");
document.querySelector(".reader-panel-tabs")?.setAttribute("role", "tablist");
const loadingIndicator = document.createElement("div");
loadingIndicator.className = "reader-loading-indicator";
loadingIndicator.setAttribute("role", "status");
loadingIndicator.innerHTML =
'<span class="reader-loading-spinner" aria-hidden="true"></span><span>正在加载正文...</span>';
content.appendChild(loadingIndicator);
content.dataset.mode = capability.mode || "unsupported";
const status = document.querySelector("#status");
const loadingStatus = document.querySelector("#loading-status");
function markReaderContentReady() {
loadingIndicator.remove();
loadingStatus.hidden = true;
}
const downloadButton = document.querySelector("#download");
const downloadFeedback = document.querySelector("#download-feedback");
let readerSourcePromise = null;
let readerRestorationPromise = null;
let readerRestorationEntry = null;
let readerRestorationLoaded = false;
let downloadFeedbackTimer = 0;
function showDownloadFeedback(message, duration = 4000) {
clearTimeout(downloadFeedbackTimer);
downloadFeedback.textContent = message;
downloadFeedback.hidden = !message;
if (message && duration) downloadFeedbackTimer = setTimeout(() => showDownloadFeedback(""), duration);
}
downloadButton.addEventListener("click", startReaderDownload);
loadingStatus.textContent = "";
const sourceName = (() => {
try {
const name = decodeURIComponent(
new URL(sourceUrl, location.href).pathname.split("/").pop() || ""
);
return name.replace(/\.[^.]+$/, "") || "在线阅读";
} catch (_) {
return "在线阅读";
}
})();
let chapterManifestObserver = null;
let contentSanitizerObserver = null;
const documentState = readerRuntime.state.document,
navigationState = readerRuntime.state.navigation,
searchState = readerRuntime.state.search,
panelState = readerRuntime.state.panel;
function updateDocumentState(patch) {
return readerRuntime.update("document", patch);
}
function updateNavigationState(patch) {
return readerRuntime.update("navigation", patch);
}
function updateSearchState(patch) {
return readerRuntime.update("search", patch);
}
function publishSearchResults(generation, results) {
return isReaderGenerationCurrent("search", generation) && updateSearchState({ results });
}
function updatePanelState(patch) {
return readerRuntime.update("panel", patch);
}
updateDocumentState({
title:
String(
params.get("title") || (localReaderData && localReaderData.title) || sourceName
).trim() || sourceName,
zoom: Math.min(4, Math.max(0.25, Number(localStorage.getItem("reader-zoom") || 100) / 100))
});
function applyReaderMetadata(data) {
if (data) resolvedReaderData = { ...(resolvedReaderData || {}), ...data };
if (data?.ReaderOcrManifest || data?.ocr_manifest) {
ocrManifestUrl = data.ReaderOcrManifest || data.ocr_manifest;
params.set("ocr_manifest", ocrManifestUrl);
}
readerRuntime.update("source", { metadata: resolvedReaderData });
const title = String(resolvedReaderData?.title || params.get("title") || sourceName).trim();
const suffix = String(
resolvedReaderData?.original_extension || (extension === "pdf-pages" ? "pdf" : extension)
)
.toLowerCase()
.replace(/^\./, "");
const value = title + (suffix && !title.toLowerCase().endsWith(`.${suffix}`) ? `.${suffix}` : "");
updateDocumentState({ title: value });
document.querySelector("#title").textContent = value;
document.title = `${value} - VoiceOfML Reader`;
applyReaderPathMetadata(resolvedReaderData);
}
let fallbackUrl = params.get("fallback") || "";
const ocrUrl = params.get("ocr") || "";
let ocrManifestUrl = params.get("ocr_manifest") || "";
let returnUrl = params.get("return") || "";
const readerPathLabel = params.get("path") || "";
const folderReturnUrl = params.get("folder_url") || "";
let returnNavigationToken = params.get("nav") || "";
let returnNeedsReload = false;
try {
const state = history.state;
const saved = JSON.parse(sessionStorage.getItem("reader-navigation-current") || "null");
let stateUrl =
state && state.voiceReaderOverlay && state.readerUrl
? new URL(state.readerUrl, location.origin)
: null;
if (!stateUrl && saved && saved.shareUrl === location.href && saved.readerUrl)
stateUrl = new URL(saved.readerUrl, location.origin);
const cleanStateUrl = stateUrl && new URL(stateUrl.href);
if (cleanStateUrl) {
cleanStateUrl.searchParams.delete("return");
cleanStateUrl.searchParams.delete("nav");
}
if (
stateUrl &&
stateUrl.origin === location.origin &&
stateUrl.pathname === "/static/reader.html" &&
cleanStateUrl.href === location.href
) {
if (!returnUrl) returnUrl = stateUrl.searchParams.get("return") || "";
if (!returnNavigationToken) returnNavigationToken = stateUrl.searchParams.get("nav") || "";
returnNeedsReload = true;
}
} catch (_) {}
const returnHistoryKey = returnNavigationToken ? `reader-return:${returnNavigationToken}` : "";
let canReturnWithHistory = false;
try {
const target = new URL(returnUrl, location.origin);
const storedReturnUrl = returnHistoryKey ? sessionStorage.getItem(returnHistoryKey) : "";
canReturnWithHistory = !!returnHistoryKey && storedReturnUrl === target.href;
} catch (_) {}
let saveTimer = 0;
let saveDeadline = 0;
let chapterManifestLoader = null;
let chapterSearchConfiguration = null;
let chapterSearchModulePromise = null;
let chapterSearchClient = null;
let chapterSearchPage = null;
let failedSearchPage = null;
let chapterSearchInProgress = false;
let fullSearchComplete = false;
trackReaderResource(() => {
chapterSearchClient?.dispose();
chapterSearchClient = null;
});
let folderNavigationTarget = null;
let bookmarkInvoker = null;
const bookmarkInertSiblings = new Map();
let pdfDocument = null;
let pdfPageManifest = null;
let pdfOcrManifest = null;
let pdfOcrManifestPromise = null;
const pdfOcrPagePromises = new Map();
const pdfTextContentCache = new Map();
const PDF_TEXT_CONTENT_CACHE_BYTES = 4 * 1024 * 1024;
let pdfTextContentCacheBytes = 0;
const pdfOcrPageCache = new Map();
const PDF_OCR_PAGE_CACHE_BYTES = 4 * 1024 * 1024;
let pdfOcrPageCacheBytes = 0;
let pdfBookSearchModulePromise = null;
let pdfBookSearchClient = null;
let pdfBookSearchPrefetchTimer = 0;
let pdfBookSearchPrefetchPromise = null;
// Do not silently download large full-book indexes just because a reader opened.
const PDF_BOOK_SEARCH_PREFETCH_MAX_BYTES = 8 * 1024 * 1024;
const PDF_BOOK_SEARCH_PREFETCH_DELAY = 6000;
trackReaderResource(() => {
pdfBookSearchClient?.dispose();
pdfBookSearchClient = null;
clearTimeout(pdfBookSearchPrefetchTimer);
pdfBookSearchPrefetchTimer = 0;
pdfBookSearchPrefetchPromise = null;
});
let pdfActiveRenders = 0;
let pdfShellsReady = Promise.resolve();
let pdfShellWindow = null;
const pdfRenderWaiters = [];
let pdfActiveTextLoads = 0;
const pdfTextWaiters = [];
let pdfUserHasScrolled = false;
const pdfManifestPrefetches = new Map();
const pdfManifestShells = [];
const PDF_MANIFEST_PREFETCH_LIMIT = 12;
const PDF_MANIFEST_PREFETCH_CONCURRENCY = 3;
// Keep the 12-page render cache; background prefetch stays a nearby window.
const PDF_MANIFEST_PREFETCH_MAX_PAGES = 48;
let pdfManifestPrefetchTimer = 0;
let pdfManifestPrefetchNext = 0;
let pdfManifestPrefetchOrigin = 0;
const pdfManifestPrefetchActive = new Set();
let pdfManifestPrefetchCount = 0;
let pdfManifestPrefetchWrapped = false;
function pdfManifestPrefetchConcurrency() {
const type = navigator.connection?.effectiveType;
if (type === "slow-2g" || type === "2g") return 1;
if (type === "3g") return 1;
if (matchMedia("(max-width: 700px)").matches) return 2;
return PDF_MANIFEST_PREFETCH_CONCURRENCY;
}
function pdfManifestPrefetchMaxPages() {
const type = navigator.connection?.effectiveType;
if (type === "slow-2g" || type === "2g") return 8;
if (type === "3g") return 16;
return matchMedia("(max-width: 700px)").matches ? 24 : PDF_MANIFEST_PREFETCH_MAX_PAGES;
}
function canPrefetchPdfImages() {
const connection = navigator.connection;
return !connection?.saveData && !["slow-2g", "2g"].includes(connection?.effectiveType);
}
function cancelPdfManifestPrefetch(record) {
if (!record) return;
pdfManifestPrefetchActive.delete(record);
clearTimeout(record.timeout);
if (pdfManifestPrefetches.get(record.url) === record) pdfManifestPrefetches.delete(record.url);
record.image.onload = record.image.onerror = null;
record.image.removeAttribute("src");
}
function stopPdfManifestPrefetch() {
clearTimeout(pdfManifestPrefetchTimer);
pdfManifestPrefetchTimer = 0;
for (const record of [...pdfManifestPrefetchActive]) cancelPdfManifestPrefetch(record);
}
function trimPdfManifestPrefetches() {
while (pdfManifestPrefetches.size > PDF_MANIFEST_PREFETCH_LIMIT) {
const victim = [...pdfManifestPrefetches.values()].find((record) => !pdfManifestPrefetchActive.has(record));
if (!victim) return;
pdfManifestPrefetches.delete(victim.url);
victim.image.onload = victim.image.onerror = null;
victim.image.removeAttribute("src");
}
}
function clearPdfManifestPrefetches() {
stopPdfManifestPrefetch();
pdfManifestPrefetchNext = 0;
pdfManifestPrefetchOrigin = 0;
pdfManifestPrefetchCount = 0;
pdfManifestPrefetchWrapped = false;
pdfManifestShells.length = 0;
for (const { image } of pdfManifestPrefetches.values()) {
image.onload = image.onerror = null;
image.removeAttribute("src");
}
pdfManifestPrefetches.clear();
}
trackReaderResource(clearPdfManifestPrefetches);
trackReaderResource(() => {
for (const waiter of pdfRenderWaiters.splice(0)) waiter.reject(readerAbortError());
for (const waiter of pdfTextWaiters.splice(0)) waiter.reject(readerAbortError());
});
let epubRendition = null;
let epubBook = null;
let foliateContinuous = false,
foliateChapterRepository = null,
foliateSectionVirtualizer = null,
foliateSectionLoader = null,
foliateSectionSettler = null,
foliateSectionObserver = null,
foliateScrollFrame = 0,
foliateWindowFrame = 0;
let initialReaderTargetSection = null;
const FOLIATE_PREFETCH_SECTIONS = 6;
const FOLIATE_PREFETCH_MARGIN = 4000;
const FOLIATE_LOADED_SECTION_LIMIT = 12;
const PDF_PREFETCH_MARGIN = 2400;
const TOC_VIRTUALIZATION_THRESHOLD = 500;
const TOC_VIRTUAL_ROW_HEIGHT = 40;
let tocVirtualState = null;
let htmlFrame = null;
let lastSavedProgress = "";
let progressSaveChain = Promise.resolve();
let historySuppressed = false;
let restorationFailed = false;
let markerFrame = 0;
let pageNavigationLockUntil = 0;
let pendingBookmarkSnapshot = null;
let mediaElement = null;
let swfPlayer = null;
function nextReaderGeneration(name) {
return readerRuntime.nextGeneration(name);
}
function isReaderGenerationCurrent(name, value) {
return !readerAbortController.signal.aborted && readerRuntime.isCurrent(name, value);
}
for (const generationName of ["navigation", "search", "bookmarks", "history", "pdf"])
nextReaderGeneration(generationName);
function beginReaderNavigation() {
if (epubSeekFrame) cancelAnimationFrame(epubSeekFrame);
epubSeekFrame = 0;
foliateScrollAnchors.invalidate();
return nextReaderGeneration("navigation");
}
function reportNavigationError(error, generation) {
if (isReaderGenerationCurrent("navigation", generation) && error?.name !== "AbortError")
console.warn("Reader navigation failed", error);
}
const viewport = document.querySelector("#viewport");
const zoomInput = document.querySelector("#zoom");
const pageInput = document.querySelector("#page-number");
const readerPath = document.querySelector("#reader-path");
const bookmarkRibbon = document.querySelector("#bookmark-ribbon");
const bookmarkPopover = document.querySelector("#bookmark-popover");
for (const [selector, label] of [
["#back", "返回"],
["#page-prev", "上一页"],
["#page-next", "下一页"],
["#page-number", "页码"],
["#zoom-out", "缩小"],
["#zoom-in", "放大"],
["#zoom", "缩放百分比"],
["#bookmark-ribbon", "添加书签"],
["#history", "阅读选项"]
])
document.querySelector(selector)?.setAttribute("aria-label", label);
function foliateSectionRoot(article) {
return article?.shadowRoot || article;
}
function foliateSectionQuery(article, selector) {
return foliateSectionRoot(article)?.querySelector(selector) || null;
}
function foliateSectionCandidates() {
return [
...content.querySelectorAll(
".foliate-continuous > article[data-section]:not(.foliate-section-placeholder), .reader-epub-chapter"
)
].flatMap((article) => [
article,
...foliateSectionRoot(article).querySelectorAll(
":is(h1,h2,h3,h4,h5,h6,p,li,blockquote,pre,img,table)"
)
]);
}
const foliateScrollAnchors = readerRuntime.track(
VoiceOfMLReaderScroll.createScrollAnchorManager({
viewport,
candidates: foliateSectionCandidates
})
);
function applyReaderPathMetadata(data) {
const folder = Array.isArray(data?.folder) ? data.folder.join("/") : String(data?.folder || "");
const value = String(
data?.path || [data?.repo, folder].filter(Boolean).join("/") || readerPathLabel
).replace(/^\/+|\/+$/g, "");
folderNavigationTarget = null;
try {
const target = new URL(folderReturnUrl, location.origin);
if (
folderReturnUrl &&
target.origin === location.origin &&
target.pathname.split("/").filter(Boolean).length === 1
)
folderNavigationTarget = target;
} catch (_) {}
if (!folderNavigationTarget && data?.repo) {
folderNavigationTarget = new URL("/" + encodeURIComponent(data.repo), location.origin);
if (folder) folderNavigationTarget.searchParams.set("folder_self", folder);
}
readerPath.textContent = value;
readerPath.hidden = !value;
readerPath.disabled = !folderNavigationTarget;
readerPath.setAttribute("aria-label", `筛选文件夹:${value}`);
}
applyReaderMetadata(resolvedReaderData);
contentSanitizerObserver = new MutationObserver(() => {
const seen = new Set();
for (const article of document.querySelectorAll(".foliate-continuous > article[data-section]")) {
article.querySelectorAll("style,link[rel=stylesheet]").forEach((node) => node.remove());
const index = article.dataset.section;
if (seen.has(index)) article.remove();
else seen.add(index);
}
});
contentSanitizerObserver.observe(content, { childList: true, subtree: true });
bookmarkPopover.className = "bookmark-popover";
bookmarkPopover.setAttribute("role", "dialog");
bookmarkPopover.setAttribute("aria-modal", "true");
bookmarkPopover.setAttribute("aria-labelledby", "bookmark-prompt");
const bookmarkLabelInput = document.createElement("input"),
bookmarkExcerptInput = document.createElement("textarea"),
bookmarkEditFields = document.createElement("div");
bookmarkLabelInput.id = "bookmark-label";
bookmarkLabelInput.maxLength = 120;
bookmarkExcerptInput.id = "bookmark-excerpt-input";
bookmarkExcerptInput.maxLength = 500;
bookmarkExcerptInput.rows = 3;
bookmarkEditFields.className = "bookmark-edit-fields";
bookmarkEditFields.innerHTML = "<label>标题</label><label>摘要</label>";
bookmarkEditFields.children[0].appendChild(bookmarkLabelInput);
bookmarkEditFields.children[1].appendChild(bookmarkExcerptInput);
bookmarkPopover.insertBefore(bookmarkEditFields, bookmarkPopover.querySelector("div"));
const bookmarksAllButton = document.createElement("button");
if (!document.querySelector("#bookmarks-panel .panel-search-toggle")) {
const button = document.createElement("button");
button.className = "panel-search-toggle";
button.type = "button";
document.querySelector("#bookmarks-panel .panel-view-header").appendChild(button);
}
bookmarksAllButton.id = "bookmarks-all";
bookmarksAllButton.className = "text-action";
bookmarksAllButton.type = "button";
bookmarksAllButton.textContent = "全部书签";
bookmarksAllButton.setAttribute("aria-pressed", "false");
const bookmarksHeader = document.querySelector("#bookmarks-panel .panel-view-header"),
bookmarksSearchButton = bookmarksHeader.querySelector(".panel-search-toggle"),
bookmarksHeaderActions = document.createElement("span");
bookmarksHeaderActions.append(bookmarksAllButton, bookmarksSearchButton);
bookmarksHeader.appendChild(bookmarksHeaderActions);
const mediaTab = document.createElement("button"),
mediaPanel = document.createElement("section");
mediaTab.id = "media-tab";
mediaTab.type = "button";
mediaTab.role = "tab";
mediaTab.dataset.panel = "media";
mediaTab.textContent = "播放";
mediaTab.hidden = !capability.features.media;
mediaPanel.id = "media-panel";
mediaPanel.className = "reader-panel-view";
mediaPanel.dataset.panelView = "media";
mediaPanel.hidden = true;
mediaPanel.innerHTML =
'<div class="media-panel-content"><strong>播放状态</strong><span class="media-panel-time">尚未播放</span><button class="text-action media-panel-bookmark" type="button">在当前时间添加书签</button></div>';
document.querySelector(".reader-panel-tabs").prepend(mediaTab);
document
.querySelector("#history-panel")
.insertBefore(mediaPanel, document.querySelector("#toc-panel"));
mediaPanel
.querySelector(".media-panel-bookmark")
.addEventListener("click", (event) => openBookmarkPopover(event.currentTarget));
document.querySelector(".page-controls").hidden = !capability.features.pagination;
document.querySelector(".zoom-controls").hidden = !capability.features.zoom;
status.hidden = true;
mediaTab.setAttribute("aria-controls", "media-panel");
mediaTab.setAttribute("aria-selected", "false");
mediaPanel.setAttribute("role", "tabpanel");
mediaPanel.setAttribute("aria-labelledby", "media-tab");
// Theme, toolbar, and document navigation.
let readerTheme = localStorage.getItem("theme") === "light" ? "light" : "dark";
const THEME_SUN_ICON =
'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="5"/><path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72 1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>';
const THEME_MOON_ICON =
'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
const readerThemeToggle = document.querySelector("#theme-toggle");
let themeAnimationTimer = 0;
readerThemeToggle.className = "theme-toggle";
function applyReaderTheme(theme, persist = true, animate = true) {
const anchor = foliateScrollAnchors.capture();
if (animate) {
clearTimeout(themeAnimationTimer);
document.documentElement.classList.add("theme-transition");
void document.documentElement.offsetWidth;
}
readerTheme = theme === "light" ? "light" : "dark";
document.documentElement.dataset.theme = readerTheme;
readerThemeToggle.innerHTML = readerTheme === "dark" ? THEME_MOON_ICON : THEME_SUN_ICON;
if (animate) {
readerThemeToggle.classList.remove("is-changing");
void readerThemeToggle.offsetWidth;
readerThemeToggle.classList.add("is-changing");
themeAnimationTimer = setTimeout(() => {
document.documentElement.classList.remove("theme-transition");
readerThemeToggle.classList.remove("is-changing");
}, 280);
}
readerThemeToggle.title = readerTheme === "dark" ? "切换到白天模式" : "切换到夜间模式";
readerThemeToggle.setAttribute("aria-label", readerThemeToggle.title);
readerThemeToggle.setAttribute("aria-pressed", String(readerTheme === "light"));
const docxBody = content.querySelector(".docx-body");
if (docxBody) docxBody.classList.toggle("reader-document-dark", readerTheme === "dark");
for (const article of content.querySelectorAll(".foliate-continuous > article"))
article.classList.toggle("reader-document-dark", readerTheme === "dark");
if (persist) localStorage.setItem("theme", readerTheme);
foliateScrollAnchors.restore(anchor);
}
applyReaderTheme(readerTheme, false, false);
function clearReturnNavigation() {
try {
if (returnHistoryKey) sessionStorage.removeItem(returnHistoryKey);
const saved = JSON.parse(sessionStorage.getItem("reader-navigation-current") || "null");
if (saved && (saved.readerUrl === location.href || saved.shareUrl === location.href))
sessionStorage.removeItem("reader-navigation-current");
} catch (_) {}
}
function navigateReaderFolder() {
if (!folderNavigationTarget) return;
if (window.parent !== window)
window.parent.postMessage(
{ type: "voice-reader-navigate", url: folderNavigationTarget.href },
location.origin
);
else {
clearReturnNavigation();
location.assign(folderNavigationTarget.href);
}
}
readerPath.addEventListener("click", navigateReaderFolder);
document.querySelector("#back").addEventListener("click", async () => {
clearTimeout(saveTimer);
await Promise.race([saveProgress(), new Promise((resolve) => setTimeout(resolve, 300))]);
if (window.parent !== window) {
window.parent.postMessage({ type: "voice-reader-close" }, location.origin);
return;
}
clearReturnNavigation();
try {
const target = new URL(returnUrl, location.origin);
if (target.origin === location.origin) {
if (returnNeedsReload) location.replace(target.href);
else if (canReturnWithHistory && history.length > 2) {
history.go(-2);
} else location.assign(target.href);
return;
}
} catch (_) {}
location.assign("/");
});
let zoomChangedByUser = false;
function setZoom(percent, persist = true) {
const anchor = foliateScrollAnchors.capture();
const normalized = VoiceOfMLReader.clampNumber(percent, 25, 400, 100);
const horizontalCenter = viewport.scrollWidth
? (viewport.scrollLeft + viewport.clientWidth / 2) / viewport.scrollWidth
: 0;
updateDocumentState({ zoom: normalized / 100 });
content.style.setProperty("--reader-zoom", String(documentState.zoom));
zoomInput.value = String(normalized);
pdfShellWindow?.resize();
if (htmlFrame && htmlFrame.contentDocument)
htmlFrame.contentDocument.documentElement.style.zoom = String(documentState.zoom);
if (pdfDocument) rerenderVisiblePdfPages();
if (capability.mode === "foliate")
content.style.setProperty("--reader-zoom", String(documentState.zoom));
viewport.scrollLeft = horizontalCenter * viewport.scrollWidth - viewport.clientWidth / 2;
if (persist) {
zoomChangedByUser = true;
localStorage.setItem("reader-zoom", String(normalized));
}
foliateScrollAnchors.restore(anchor);
if (persist) scheduleSave();
}
const handlePageNavigationFailure = (error, generation) => {
if (isReaderGenerationCurrent("navigation", generation) && error?.name !== "AbortError")
fail("原文件加载失败,请检查网络后重试,或下载原文件。", "READER_NAVIGATION");
};
setZoom(documentState.zoom * 100, false);
for (const [id, delta] of [
["#zoom-out", -10],
["#zoom-in", 10]
]) {
document.querySelector(id).addEventListener("click", () => {
setZoom(Number(zoomInput.value) + delta);
});
}
zoomInput.addEventListener("change", () => setZoom(zoomInput.value));
zoomInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
setZoom(zoomInput.value);
zoomInput.blur();
}
});
pageInput.addEventListener("change", () => goToPage(pageInput.value));
pageInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
goToPage(pageInput.value);
pageInput.blur();
}
});
function turnViewport(direction) {
beginReaderNavigation();
const target = htmlFrame?.contentWindow || viewport;
target.scrollBy({
top:
Math.max(160, (htmlFrame?.contentWindow?.innerHeight || viewport.clientHeight) * 0.86) *
direction,
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth"
});
}
function handleReaderKeydown(event) {
if (
event.defaultPrevented ||
event.isComposing ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.target.closest?.(
"input,textarea,select,button,a,audio,video,[contenteditable]:not([contenteditable=false])"
)
)
return;
if (["PageDown", " ", "ArrowDown", "PageUp", "ArrowUp"].includes(event.key)) {
event.preventDefault();
turnViewport(
["PageUp", "ArrowUp"].includes(event.key) || (event.key === " " && event.shiftKey) ? -1 : 1
);
}
}
document.addEventListener("keydown", handleReaderKeydown);
document
.querySelector("#page-prev")
.addEventListener("click", () =>
documentState.pageCount ? goToPage(documentState.page - 1) : turnViewport(-1)
);
document
.querySelector("#page-next")
.addEventListener("click", () =>
documentState.pageCount ? goToPage(documentState.page + 1) : turnViewport(1)
);
async function goToPage(value, generation = beginReaderNavigation()) {
try {
if (!documentState.pageCount || !isReaderGenerationCurrent("navigation", generation))
return false;
const page = VoiceOfMLReader.clampNumber(value, 1, documentState.pageCount, 1);
pdfShellWindow?.ensure(page);
let shell = content.querySelector(
`.reader-page[data-page="${page}"], .reader-docx-page[data-page="${page}"]`
);
if (!shell && capability.mode === "pdf-pages") {
await pdfShellsReady;
shell = content.querySelector(`.reader-page[data-page="${page}"]`);
}
if (!isReaderGenerationCurrent("navigation", generation)) return false;
if (!shell) return false;
const imagePage = capability.mode === "pdf-pages";
if (imagePage && shell.dataset.renderState !== "rendered")
prioritizePdfManifestPrefetch(pdfPageManifest?.pageUrl(page));
const pendingPdf =
!imagePage && shell.classList.contains("reader-page")
? renderPdfInBackground(shell, false, true)
: null;
if (!isReaderGenerationCurrent("navigation", generation)) return false;
viewport.scrollTop = shell.offsetTop;
if (imagePage && shell.dataset.renderState === "rendered") renderPdfOcrText(shell);
pageNavigationLockUntil = performance.now() + 500;
if (pendingPdf) cancelSpeculativePdfRenders(shell);
const pendingImage = imagePage ? renderPdfInBackground(shell, false, true) : null;
if (imagePage && shell.dataset.renderState !== "rendered") cancelSpeculativePdfRenders(shell);
updateDocumentState({ page });
pageInput.value = String(page);
updateTocCurrentMark();
updateProgressTools();
pendingPdf?.catch(() => {});
pendingImage?.catch(() => {});
if (!isReaderGenerationCurrent("navigation", generation)) return false;
scheduleSave();
return true;
} catch (error) {
handlePageNavigationFailure(error, generation);
return false;
}
}
function scheduleSave() {
if (readerLifecycle.disposed || readerAbortController.signal.aborted) return;
const now = performance.now();
if (!saveDeadline) saveDeadline = now + 2000;
readerRuntime.cancel(saveTimer);
saveTimer = readerRuntime.schedule(
() => {
saveTimer = 0;
saveDeadline = 0;
saveProgress();
},
Math.max(0, Math.min(500, saveDeadline - now))
);
}
async function saveProgress(event) {
if (
readerLifecycle.disposed ||
!documentState.restorationReady ||
!validSource(sourceUrl) ||
historySuppressed
)
return;
if (event?.type !== "pagehide" && document.visibilityState !== "hidden")
syncCurrentPageFromMarker();
const shell = documentState.pageCount
? content.querySelector(
`.reader-page[data-page="${documentState.page}"], .reader-docx-page[data-page="${documentState.page}"]`
)
: null;
const pageOffset = shell ? Math.max(0, viewport.scrollTop - shell.offsetTop) : 0;
const htmlScrollTop = htmlFrame && htmlFrame.contentWindow ? htmlFrame.contentWindow.scrollY : 0;
const readerUrl = currentReaderUrl();
const foliatePosition = captureFoliateBookmarkPosition() || captureChapterPosition();
const progress = {
url: sourceUrl,
title: documentState.title,
extension,
readerUrl: readerUrl.href,
page: documentState.page,
pageCount: documentState.pageCount,
pageOffset,
mediaTime:
mediaElement && Number.isFinite(mediaElement.currentTime) ? mediaElement.currentTime : 0,
scrollTop: viewport.scrollTop,
htmlScrollTop,
zoom: Math.round(documentState.zoom * 100),
...(foliatePosition || {})
};
const signature = JSON.stringify(progress);
if (signature === lastSavedProgress) return progressSaveChain;
lastSavedProgress = signature;
progressSaveChain = progressSaveChain
.catch(() => {})
.then(() => {
if (!historySuppressed)
return VoiceOfMLReaderStore.put({ ...progress, lastReadAt: Date.now() });
})
.catch((error) => {
if (lastSavedProgress === signature) lastSavedProgress = "";
console.warn("Reader progress was not saved", error);
});
return progressSaveChain;
}
function emptyPanel(list, message) {
list.innerHTML = `<div class="panel-empty">${message}</div>`;
}
function clearSearchHighlights(view) {
for (const mark of view.querySelectorAll("mark.search-match")) mark.replaceWith(mark.textContent);
view.normalize();
}
function highlightPanelItem(item, query) {
const nodes = [];
const walker = document.createTreeWalker(item, NodeFilter.SHOW_TEXT);
while (walker.nextNode())
if (!walker.currentNode.parentElement.closest(".panel-item-remove"))
nodes.push(walker.currentNode);
const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "giu");
for (const node of nodes) {
const text = node.data,
matches = [...text.matchAll(pattern)];
if (!matches.length) continue;
let start = 0;
const fragment = document.createDocumentFragment();
for (const match of matches) {
fragment.append(text.slice(start, match.index));
const mark = document.createElement("mark");
mark.className = "search-match";
mark.textContent = match[0];
fragment.append(mark);
start = match.index + match[0].length;
}
fragment.append(text.slice(start));
node.replaceWith(fragment);
}
}
function filterPanel(view) {
clearSearchHighlights(view);
const query = (view.querySelector(".panel-search").value || "").trim().toLowerCase();
for (const item of view.querySelectorAll(".panel-item")) {
const searchableText = [...item.children]
.filter((child) => !child.classList.contains("panel-item-remove"))
.map((child) => child.textContent)
.join(" ")
.toLowerCase();
item.hidden = !!query && !searchableText.includes(query);
if (query && !item.hidden) highlightPanelItem(item, query);
}
}
let panelAnimationTimer = 0;
let readerPanelInteractionSerial = 0;
let readerUiHistoryState = "base";
if (window.parent === window) {
history.replaceState({ ...history.state, voiceReaderBase: true }, "", location.href);
history.pushState({ voiceReaderGuard: true }, "", location.href);
window.addEventListener("popstate", (event) => {
if (!event.state?.voiceReaderBase || readerLifecycle.disposed) return;
if (document.querySelector("#history-panel").classList.contains("is-open") && !fullSearchView.hidden)
closeFullSearchView();
else if (document.querySelector("#history-panel").classList.contains("is-open"))
setReaderPanelOpen(false, true);
else {
history.back();
return;
}
history.pushState({ voiceReaderGuard: true }, "", location.href);
});
}
function notifyReaderUiState(state) {
readerUiHistoryState = state;
if (window.parent !== window)
window.parent.postMessage({ type: "voice-reader-ui-state", state }, location.origin);
}
function setReaderPanelOpen(open, restoreFocus = false) {
readerRuntime.update("panel", { open });
const panel = document.querySelector("#history-panel");
clearTimeout(panelAnimationTimer);
document.querySelector("#history").setAttribute("aria-expanded", String(open));
if (open) {
readerPanelInteractionSerial++;
panel.hidden = false;
void panel.offsetWidth;
panel.classList.add("is-open");
selectPanel(panelState.selected === "full-search" ? "full-search" : mediaElement ? "media" : "toc");
updateTocCurrentMark();
notifyReaderUiState(panelState.selected === "full-search" ? "full-search" : "panel");
return;
}
panel.classList.remove("is-open");
panelAnimationTimer = setTimeout(() => {
if (!panel.classList.contains("is-open")) panel.hidden = true;
}, 250);
notifyReaderUiState("base");
if (restoreFocus) document.querySelector("#history").focus({ preventScroll: true });
}
function setPanelSearchOpen(button, open) {
const input = button.closest(".reader-panel-view").querySelector(".panel-search");
button.setAttribute("aria-expanded", String(open));
if (open) {
input.hidden = false;
void input.offsetWidth;
input.classList.add("is-open");
input.focus();
return;
}
input.classList.remove("is-open");
setTimeout(() => {
if (!input.classList.contains("is-open")) input.hidden = true;
}, 190);
button.focus();
}
function selectPanel(name) {
if (name === "media" && mediaTab.hidden) name = "bookmarks";
updatePanelState({ selected: name });
if (progressTools) progressTools.hidden = !["toc", "media"].includes(name);
for (const button of document.querySelectorAll(".reader-panel-tabs button")) {
const selected = button.dataset.panel === name;
button.setAttribute("aria-selected", String(selected));
button.tabIndex = selected ? 0 : -1;
}
for (const view of document.querySelectorAll(".reader-panel-view"))
view.hidden = view.dataset.panelView !== name;
if (name === "full-search") notifyReaderUiState("full-search");
else if (panelState.open) notifyReaderUiState("panel");
if (name === "bookmarks") renderBookmarks();
if (name === "history") renderHistory();
if (name === "media" && mediaElement)
mediaPanel.querySelector(".media-panel-time").textContent =
`${formatMediaTime(mediaElement.currentTime)} / ${formatMediaTime(mediaElement.duration)}`;
updateProgressTools();
}
function headingTocEntries(root) {
return [...root.querySelectorAll("h1,h2,h3,h4,h5,h6")]
.filter((heading) => heading.textContent.trim())
.map((target) => ({
label: target.textContent.trim(),
depth: Number(target.tagName.slice(1)) - 1,
target,
activate: () => target.scrollIntoView({ block: "start" })
}));
}
function syncHeadingLocation() {
const marker = htmlFrame ? 8 : viewport.getBoundingClientRect().top + 8;
let current = -1,
currentPosition = -Infinity,
first = -1,
firstPosition = Infinity;
for (const [index, entry] of navigationState.tocEntries.entries()) {
if (!entry.target?.isConnected) continue;
const position = entry.target.getBoundingClientRect().top;
if (position <= marker && (position > currentPosition || (position === currentPosition && index > current))) {
current = index;
currentPosition = position;
}
if (position > marker && position < firstPosition) {
first = index;
firstPosition = position;
}
}
if (current < 0) current = first;
if (current >= 0 && current !== navigationState.currentChapterIndex) {
updateNavigationState({ currentChapterIndex: current });
updateTocCurrentMark();
}
}
function handleReaderPositionChange() {
if (readerAbortController.signal.aborted) return;
pdfShellWindow?.schedule();
scheduleMarkerSync();
scheduleFoliateScrollSync();
foliateScrollAnchors.remember();
}
async function navigateTocEntry(index) {
if (index < 0 || index >= navigationState.tocEntries.length) return;
if (navigationState.tocEntries[index].group) return;
const generation = beginReaderNavigation(),
entry = navigationState.tocEntries[index];
try {
const activated = foliateContinuous
? await activateFoliateTocEntry(entry, generation)
: await entry.activate(generation);
if (activated === false || !isReaderGenerationCurrent("navigation", generation)) return;
updateNavigationState({ currentChapterIndex: index });
updateTocCurrentMark();
updateProgressTools();
setReaderPanelOpen(false, true);
} catch (error) {
reportNavigationError(error, generation);
}
}
function createTocRow(entry, index) {
const row = document.createElement("div");
row.className = "panel-item toc-item";
row.dataset.tocIndex = String(index);
row.style.setProperty("--toc-depth", String(entry.depth || 0));
const link = document.createElement("div");
link.className = "panel-item-main";
link.textContent = entry.label;
link.title = entry.label;
if (entry.group) {
row.classList.add("toc-group");
link.setAttribute("role", "heading");
link.setAttribute("aria-level", String(Math.min(6, (entry.depth || 0) + 1)));
row.appendChild(link);
return row;
}
link.tabIndex = 0;
link.setAttribute("role", "link");
const activate = () => {
if (!getSelection().toString()) navigateTocEntry(index);
};
link.addEventListener("click", activate);
link.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activate();
}
});
row.appendChild(link);
return row;
}
function renderVirtualToc() {
const state = tocVirtualState;
if (!state) return;
const height = Math.max(320, state.panelView.clientHeight),
start = Math.max(0, Math.floor(state.panelView.scrollTop / TOC_VIRTUAL_ROW_HEIGHT) - 12),
end = Math.min(state.entries.length, start + Math.ceil(height / TOC_VIRTUAL_ROW_HEIGHT) + 24),
fragment = document.createDocumentFragment();
state.list.replaceChildren();
for (let index = start; index < end; index++) {
const row = createTocRow(state.entries[index], index);
row.classList.add("toc-virtual-row");
row.style.top = `${index * TOC_VIRTUAL_ROW_HEIGHT}px`;
fragment.appendChild(row);
}
state.list.appendChild(fragment);
updateTocCurrentMark();
}
function setToc(entries) {
const normalizedEntries = (entries || []).map((entry) => ({
...entry,
href: String(entry.href || ""),
label: cleanTocLabel(entry.label)
}));
updateNavigationState({
tocEntries: normalizedEntries,
currentChapterIndex: normalizedEntries.findIndex((entry) => !entry.group)
});
const tocTab = document.querySelector("#toc-tab");
tocTab.hidden = false;
tocTab.textContent = normalizedEntries.length ? "目录" : "阅读";
const previousList = document.querySelector("#toc-list"),
list = previousList.cloneNode(false);
tocVirtualState?.destroy();
tocVirtualState = null;
previousList.replaceWith(list);
const virtualizationThreshold = chapterManifestUrl ? 2000 : TOC_VIRTUALIZATION_THRESHOLD;
if (normalizedEntries.length > virtualizationThreshold) {
const panelView = list.parentElement;
list.classList.add("toc-list-virtualized");
list.style.height = `${normalizedEntries.length * TOC_VIRTUAL_ROW_HEIGHT}px`;
let frame = 0;
const onScroll = () => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
renderVirtualToc();
});
};
panelView.addEventListener("scroll", onScroll, { passive: true });
tocVirtualState = {
entries: normalizedEntries,
list,
panelView,
render: renderVirtualToc,
destroy: () => {
panelView.removeEventListener("scroll", onScroll);
if (frame) cancelAnimationFrame(frame);
}
};
renderVirtualToc();
} else {
for (const [index, entry] of normalizedEntries.entries())
list.appendChild(createTocRow(entry, index));
}
updateTocCurrentMark();
}
function cleanTocLabel(value) {
return (
String(value || "未命名章节")
.replace(/[\uFFFD\u2610-\u2612\u25a1\u0000-\u001F\u007F]/gu, "")
.replace(/\s{2,}/gu, " ")
.trim() || "未命名章节"
);
}
function updateTocCurrentMark() {
const rows = [...document.querySelectorAll("#toc-list .toc-item")];
if (documentState.pageCount > 1 && documentState.page > 0) {
let pageIndex = -1;
for (const [index, entry] of navigationState.tocEntries.entries()) {
const match = entry.label.match(/第\s*(\d+)\s*页/u);
if (match && Number(match[1]) <= documentState.page) pageIndex = index;
}
if (pageIndex >= 0) updateNavigationState({ currentChapterIndex: pageIndex });
}
for (const row of rows) {
const index = Number(row.dataset.tocIndex);
let mark = row.querySelector(".toc-current-mark");
if (!mark) {
mark = document.createElement("span");
mark.className = "toc-current-mark";
mark.textContent = "✓";
row.appendChild(mark);
}
mark.hidden = index !== navigationState.currentChapterIndex;
row.classList.toggle("is-current", index === navigationState.currentChapterIndex);
}
}
function syncFoliateScrollLocation() {
if (!foliateContinuous || !navigationState.tocEntries.length) return;
const rows = [...document.querySelectorAll(".foliate-continuous article[data-section]")],
top = viewport.getBoundingClientRect().top + 80;
let sectionIndex = -1,
sectionNode = null;
for (const row of rows)
if (row.getBoundingClientRect().top <= top) {
sectionIndex = Number(row.dataset.section);
sectionNode = row;
}
if (!sectionNode) return;
let best = -1;
let bestPosition = -Infinity;
for (const [index, entry] of navigationState.tocEntries.entries()) {
if (entry.sectionIndex !== sectionIndex) continue;
let position = sectionNode.getBoundingClientRect().top;
if (entry.anchor) {
const root = foliateSectionRoot(sectionNode);
const anchor = entry.anchor({
getElementById: (id) => root.querySelector(`[id="${CSS.escape(id)}"]`),
querySelector: (selector) => root.querySelector(selector)
});
if (anchor) position = anchor.getBoundingClientRect().top;
}
if (entry.fragment) {
const anchor = foliateSectionQuery(
sectionNode,
`[id="${CSS.escape(entry.fragment)}"], [name="${CSS.escape(entry.fragment)}"]`
);
if (anchor) position = anchor.getBoundingClientRect().top;
}
if (position <= top && (position > bestPosition || (position === bestPosition && index > best))) {
best = index;
bestPosition = position;
}
}
if (best < 0) {
let firstPosition = Infinity;
for (const [index, entry] of navigationState.tocEntries.entries()) {
if (entry.sectionIndex !== sectionIndex) continue;
const position = entry.target?.getBoundingClientRect?.().top;
if (Number.isFinite(position) && position < firstPosition) {
best = index;
firstPosition = position;
}
}
}
if (best >= 0 && best !== navigationState.currentChapterIndex) {
updateNavigationState({ currentChapterIndex: best });
updateTocCurrentMark();
updateProgressTools();
}
}
function refreshFoliateWindow() {
foliateWindowFrame = 0;
if (!foliateChapterRepository) return;
const viewportRect = viewport.getBoundingClientRect();
for (const placeholder of document.querySelectorAll(
".foliate-continuous article.foliate-section-placeholder[data-section]"
)) {
const rect = placeholder.getBoundingClientRect();
if (
rect.bottom > viewportRect.top - FOLIATE_PREFETCH_MARGIN &&
rect.top < viewportRect.bottom + FOLIATE_PREFETCH_MARGIN
)
foliateChapterRepository.load(Number(placeholder.dataset.section)).catch(() => {});
}
}
function scheduleFoliateWindowRefresh() {
if (!foliateWindowFrame) foliateWindowFrame = requestAnimationFrame(refreshFoliateWindow);
}
function trimFoliateSections() {
if (!foliateContinuous || !foliateSectionVirtualizer) return;
const marker = viewport.getBoundingClientRect().top + 80,
rows = [...document.querySelectorAll(".foliate-continuous article[data-section]")],
current =
rows.find((row) => {
const rect = row.getBoundingClientRect();
return rect.top <= marker && rect.bottom > marker;
}) || rows.find((row) => row.getBoundingClientRect().top > marker);
if (current) foliateSectionVirtualizer.trim(Number(current.dataset.section));
}
function scheduleFoliateScrollSync() {
if (foliateScrollFrame) return;
foliateScrollFrame = requestAnimationFrame(() => {
foliateScrollFrame = 0;
syncFoliateScrollLocation();
foliateScrollAnchors.whenIdle(trimFoliateSections);
});
}
async function activateFoliateTocEntry(entry, generation = beginReaderNavigation()) {
const book = epubRendition?.book,
href = String(entry.href || ""),
target = await epubRendition?.book?.resolveHref(href);
if (!book || !isReaderGenerationCurrent("navigation", generation)) return false;
const section = target && book.sections[target.index],
sections = book.sections.filter((item) => item.linear !== "no"),
visibleIndex = section ? sections.indexOf(section) : -1;
if (!section || visibleIndex < 0 || !foliateSectionLoader) return false;
let node = await foliateSectionLoader(visibleIndex);
if (!isReaderGenerationCurrent("navigation", generation)) return false;
if (foliateSectionSettler) await foliateSectionSettler();
if (!isReaderGenerationCurrent("navigation", generation)) return false;
if (!node?.isConnected) node = await foliateSectionLoader(visibleIndex);
if (!node?.isConnected || !isReaderGenerationCurrent("navigation", generation)) return false;
const root = foliateSectionRoot(node),
anchorDocument = {
getElementById: (id) => root.querySelector(`[id="${CSS.escape(id)}"]`),
querySelector: (selector) => root.querySelector(selector)
};
let anchor = null;
try {
anchor = typeof target.anchor === "function" ? target.anchor(anchorDocument) : null;
} catch (_) {}
if (!anchor) {
const fragment = href.split("#")[1] || "",
id = fragment ? decodeURIComponent(fragment) : "";
anchor = id ? root.querySelector(`[id="${CSS.escape(id)}"], [name="${CSS.escape(id)}"]`) : null;
}
const destination = anchor || node;
foliateScrollAnchors.invalidate();
viewport.scrollTop = Math.max(
0,
viewport.scrollTop +
destination.getBoundingClientRect().top -
viewport.getBoundingClientRect().top -
8
);
syncFoliateScrollLocation();
updateTocCurrentMark();
updateProgressTools();
scheduleSave();
setReaderPanelOpen(false, true);
return true;
}
document.addEventListener("click", async (event) => {
const path = event.composedPath(),
link = path.find((node) => node instanceof Element && node.matches?.("a[href]")),
article = path.find(
(node) =>
node instanceof Element && node.matches?.(".foliate-continuous article[data-section]")
);
if (!link || !article || !epubRendition?.book) return;
const raw = link.getAttribute("href") || "";
if (raw.startsWith("/api/reader-resource?")) return;
if (!raw || epubRendition.book.isExternal?.(raw) || /^(?:https?:|mailto:|tel:)/i.test(raw))
return;
event.preventDefault();
const generation = beginReaderNavigation(),
index = Number(article.dataset.section),
section = epubRendition.book.sections.filter((item) => item.linear !== "no")[index];
let href = raw;
try {
href = section?.resolveHref?.(raw) || raw;
} catch (_) {}
try {
await activateFoliateTocEntry({ href }, generation);
} catch (error) {
reportNavigationError(error, generation);
}
});
function placeReadingProgress() {
const panel = document.querySelector("#history-panel"),
tocPanel = document.querySelector("#toc-panel"),
tocList = tocPanel.querySelector(".panel-list"),
noToc = loadingStatus.hidden && !navigationState.tocEntries.length && !mediaElement;
progressTools.classList.toggle("reader-no-toc", noToc);
tocPanel.querySelector(".panel-view-header strong").textContent = noToc ? "阅读状态" : "目录";
if (
noToc &&
(progressTools.parentElement !== tocPanel || progressTools.nextElementSibling !== tocList)
)
tocPanel.insertBefore(progressTools, tocList);
else if (!noToc && panel.lastElementChild !== progressTools) panel.appendChild(progressTools);
}
function currentReaderUrl() {
const url = new URL(location.href);
for (const key of ["return", "nav", "bookmark", "bookmark_source"]) url.searchParams.delete(key);
return url;
}
async function loadReaderRestoration() {
const currentEntry = (entry) => {
if (
capability.mode === "epub-chapters" &&
/-epub-chapters-v5\//.test(chapterManifestUrl || sourceUrl) &&
entry?.chapterManifest !== (chapterManifestUrl || sourceUrl)
) return null;
return entry;
};
try {
const bookmarkId = params.get("bookmark"),
bookmarkSource = params.get("bookmark_source");
if (bookmarkId && bookmarkSource === sourceUrl) {
const entries = await VoiceOfMLReaderStore.listBookmarks(sourceUrl);
const bookmark = entries.find((entry) => entry.id === bookmarkId && entry.url === sourceUrl);
if (bookmark) return currentEntry(bookmark);
}
return currentEntry(await VoiceOfMLReaderStore.get(sourceUrl));
} catch (_) {
restorationFailed = true;
return null;
}
}
async function getInitialReaderRestoration() {
if (!readerRestorationPromise) return null;
if (!readerRestorationLoaded) {
readerRestorationEntry = await awaitReader(Promise.race([
readerRestorationPromise,
waitForReader(150).then(() => null)
]));
readerRestorationLoaded = true;
}
return readerRestorationEntry;
}
function initialReaderPage(total) {
const page = Number(readerRestorationEntry?.page);
return Number.isInteger(page) ? Math.max(1, Math.min(total, page)) : 1;
}
function consumeBookmarkHandoff(entry) {
if (!entry || entry.id !== params.get("bookmark") || entry.url !== params.get("bookmark_source"))
return;
const opening = new URL(location.href);
const shareKey = (url) => {
const key = new URL(url);
key.searchParams.delete("return");
key.searchParams.delete("nav");
return key.href;
};
const cleanMatchingUrl = (raw) => {
if (typeof raw !== "string" || !raw) return null;
try {
const url = new URL(raw, location.origin);
if (
url.origin !== location.origin ||
url.pathname !== opening.pathname ||
shareKey(url) !== shareKey(opening)
)
return null;
url.searchParams.delete("bookmark");
url.searchParams.delete("bookmark_source");
return url.href;
} catch (_) {
return null;
}
};
const cleanHistory = (owner) => {
const url = cleanMatchingUrl(owner.location.href);
if (!url) return;
const state = owner.history.state;
const readerUrl = state?.voiceReaderOverlay && cleanMatchingUrl(state.readerUrl);
owner.history.replaceState(readerUrl ? { ...state, readerUrl } : state, "", url);
};
// Clean only this navigation, not a bookmark-ID cache. A new deliberate click
// can reuse the same bookmark; overlay reload/Back uses the cleaned records.
try {
if (
window.parent !== window &&
window.parent.location.origin === location.origin &&
window.frameElement?.matches("iframe.reader-overlay") &&
window.parent.history.state?.voiceReaderOverlay === true &&
cleanMatchingUrl(window.parent.history.state.readerUrl)
)
cleanHistory(window.parent);
} catch (_) {}
try {
const saved = JSON.parse(sessionStorage.getItem("reader-navigation-current") || "null");
const readerUrl = cleanMatchingUrl(saved?.readerUrl),
shareUrl = cleanMatchingUrl(saved?.shareUrl);
if (readerUrl && shareUrl)
sessionStorage.setItem(
"reader-navigation-current",
JSON.stringify({ ...saved, readerUrl, shareUrl })
);
} catch (_) {}
try {
cleanHistory(window);
} catch (_) {}
params.delete("bookmark");
params.delete("bookmark_source");
}
async function navigateReader(rawUrl, bookmark = null) {
let target;
try {
target = new URL(rawUrl, location.origin);
if (
target.origin !== location.origin ||
target.pathname !== "/static/reader.html" ||
target.username ||
target.password
)
return;
target.searchParams.delete("bookmark");
target.searchParams.delete("bookmark_source");
if (bookmark) {
if (
typeof bookmark.id !== "string" ||
!bookmark.id ||
typeof bookmark.url !== "string" ||
!bookmark.url
)
return;
target.searchParams.set("bookmark", bookmark.id);
target.searchParams.set("bookmark_source", bookmark.url);
}
} catch (_) {
return;
}
const generation = beginReaderNavigation();
await saveProgress();
if (!isReaderGenerationCurrent("navigation", generation)) return;
if (window.parent !== window)
window.parent.postMessage({ type: "voice-reader-open", url: target.href }, location.origin);
else location.assign(target.href);
}
async function renderHistory() {
const list = document.querySelector("#history-list"),
generation = nextReaderGeneration("history");
try {
const entries = await VoiceOfMLReaderStore.list();
if (!isReaderGenerationCurrent("history", generation)) return;
list.textContent = "";
for (const entry of entries) {
const row = document.createElement("div");
row.className = "panel-item";
if (entry.mediaTime != null) row.dataset.mediaTime = String(entry.mediaTime);
const link = document.createElement("button");
link.type = "button";
link.className = "panel-item-main";
link.textContent = entry.title || entry.url;
link.addEventListener("click", () => navigateReader(entry.readerUrl));
const meta = document.createElement("small");
meta.textContent = `${entry.pageCount ? `第 ${entry.page || 1} / ${entry.pageCount} 页 · ` : ""}${new Date(entry.lastReadAt).toLocaleString()}`;
const remove = document.createElement("button");
remove.type = "button";
remove.className = "panel-item-remove";
remove.textContent = "删除";
remove.addEventListener("click", async () => {
if (entry.url === sourceUrl) {
historySuppressed = true;
await progressSaveChain;
}
await VoiceOfMLReaderStore.remove(entry.url);
renderHistory();
});
row.append(link, remove, meta);
list.appendChild(row);
}
if (!list.childElementCount) emptyPanel(list, "暂无阅读记录");
filterPanel(document.querySelector("#history-view"));
} catch (_) {
if (isReaderGenerationCurrent("history", generation)) emptyPanel(list, "无法读取本地记录");
}
}
function readerProgressPercent() {
if (mediaElement)
return mediaElement.duration
? Math.round((mediaElement.currentTime / mediaElement.duration) * 1000) / 10
: 0;
if (foliateContinuous) {
const position = captureFoliateBookmarkPosition(),
count = epubBook?.sections?.filter((section) => section.linear !== "no").length || 0,
article =
position &&
document.querySelector(
`.foliate-continuous article[data-section="${position.foliateSection}"]`
),
fraction = article
? Math.max(
0,
Math.min(
1,
position.foliateOffset / Math.max(1, article.getBoundingClientRect().height)
)
)
: 0;
return count && position
? Math.round(((position.foliateSection + fraction) / count) * 1000) / 10
: 0;
}
if (htmlFrame && htmlFrame.contentDocument) {
const doc = htmlFrame.contentDocument.documentElement,
win = htmlFrame.contentWindow;
return (
Math.round(
Math.max(0, Math.min(1, win.scrollY / Math.max(1, doc.scrollHeight - win.innerHeight))) *
1000
) / 10
);
}
return (
Math.round(
Math.max(
0,
Math.min(1, viewport.scrollTop / Math.max(1, viewport.scrollHeight - viewport.clientHeight))
) * 1000
) / 10
);
}
function excerptFromCaret(doc, root, x, y) {
let node,
offset = 0;
const position = doc.caretPositionFromPoint ? doc.caretPositionFromPoint(x, y) : null;
if (position) {
node = position.offsetNode;
offset = position.offset;
} else if (doc.caretRangeFromPoint) {
const range = doc.caretRangeFromPoint(x, y);
if (range) {
node = range.startContainer;
offset = range.startOffset;
}
}
if (!node || !root.contains(node)) return "";
if (node.nodeType !== Node.TEXT_NODE) {
const first = doc.createTreeWalker(node, NodeFilter.SHOW_TEXT).nextNode();
if (!first) return "";
node = first;
offset = 0;
}
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
walker.currentNode = node;
let excerpt = node.data.slice(offset);
while (excerpt.length < 220 && walker.nextNode()) excerpt += ` ${walker.currentNode.data}`;
return excerpt.replace(/\s+/g, " ").trim().slice(0, 160);
}
function bookmarkExcerpt() {
if (["image", "audio", "video", "pdf-pages"].includes(capability.mode)) return "";
if (capability.mode === "pdf") {
const shell = pageAtMarker(),
items = shell && shell._bookmarkTextItems;
if (items && items.length) {
const rect = shell.getBoundingClientRect(),
targetY =
Math.max(0, bookmarkRibbon.getBoundingClientRect().bottom - rect.top) /
Math.max(1, rect.height);
let nearest = 0,
distance = Infinity;
items.forEach((item, index) => {
const nextDistance = Math.abs(item.y - targetY);
if (nextDistance < distance) {
nearest = index;
distance = nextDistance;
}
});
return items
.slice(nearest)
.map((item) => item.text)
.join(" ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 160);
}
// A scanned/unloaded page has no excerpt. Never fall back to other pages' UI text.
return "";
}
const x = Math.round(viewport.getBoundingClientRect().width / 2),
y = Math.round(bookmarkRibbon.getBoundingClientRect().bottom + 8);
const frames = [...document.querySelectorAll("iframe")].filter((frame) => {
const rect = frame.getBoundingClientRect();
return rect.left <= x && rect.right >= x && rect.top <= y && rect.bottom >= y;
});
const frame = frames[frames.length - 1];
try {
if (frame && frame.contentDocument && frame.contentDocument.body) {
const rect = frame.getBoundingClientRect();
return excerptFromCaret(
frame.contentDocument,
frame.contentDocument.body,
x - rect.left,
y - rect.top
);
}
} catch (_) {}
const exact = excerptFromCaret(document, content, x, y);
if (exact) return exact;
const sourceText = foliateContinuous
? [
...content.querySelectorAll(
".foliate-continuous article[data-section]:not(.foliate-section-placeholder)"
)
]
.map((article) => foliateSectionRoot(article).textContent)
.join(" ")
: content.textContent,
text = sourceText.replace(/\s+/g, " ").trim(),
start = Math.floor((text.length * readerProgressPercent()) / 100);
return text.slice(start, start + 160).trim();
}
function captureFoliateBookmarkPosition() {
if (!foliateContinuous) return null;
const marker = viewport.getBoundingClientRect().top + 8,
rows = [...document.querySelectorAll(".foliate-continuous article[data-section]")];
const article =
rows.find((row) => {
const rect = row.getBoundingClientRect();
return rect.top <= marker && rect.bottom > marker;
}) ||
rows.find((row) => row.getBoundingClientRect().top > marker) ||
rows[rows.length - 1];
return article
? {
foliateSection: Number(article.dataset.section),
foliateOffset: marker - article.getBoundingClientRect().top,
foliateTocIndex: navigationState.currentChapterIndex
}
: null;
}
async function restoreFoliateBookmarkPosition(entry, generation = beginReaderNavigation()) {
try {
if (
!foliateSectionLoader ||
!Number.isInteger(entry.foliateSection) ||
!isReaderGenerationCurrent("navigation", generation)
)
return false;
let article = await foliateSectionLoader(entry.foliateSection);
if (!isReaderGenerationCurrent("navigation", generation)) return false;
if (foliateSectionSettler && initialReaderTargetSection !== entry.foliateSection)
await foliateSectionSettler();
if (!isReaderGenerationCurrent("navigation", generation)) return false;
if (!article?.isConnected) article = await foliateSectionLoader(entry.foliateSection);
if (!article?.isConnected || !isReaderGenerationCurrent("navigation", generation)) return false;
const marker = viewport.getBoundingClientRect().top + 8;
const offset = Number(entry.foliateOffset) || 0;
const start = !offset
? foliateSectionRoot(article).querySelector("h1,h2,h3,h4,h5,h6,p,li,blockquote,pre") || article
: article;
foliateScrollAnchors.invalidate();
viewport.scrollTop = Math.max(
0,
viewport.scrollTop +
start.getBoundingClientRect().top -
marker +
offset
);
syncFoliateScrollLocation();
updateTocCurrentMark();
updateProgressTools();
scheduleSave();
setReaderPanelOpen(false, true);
return true;
} catch (error) {
reportNavigationError(error, generation);
return false;
}
}
async function seekFoliateProgress(percent, generation) {
try {
const sections = epubBook?.sections?.filter((section) => section.linear !== "no") || [];
if (
!sections.length ||
!foliateSectionLoader ||
!isReaderGenerationCurrent("navigation", generation)
)
return;
const position = Math.max(0, Math.min(0.999999, Number(percent) / 100)) * sections.length,
index = Math.min(sections.length - 1, Math.floor(position));
let article = await foliateSectionLoader(index);
if (!isReaderGenerationCurrent("navigation", generation)) return;
if (foliateSectionSettler) await foliateSectionSettler();
if (!isReaderGenerationCurrent("navigation", generation)) return;
if (!article?.isConnected) article = await foliateSectionLoader(index);
if (!article?.isConnected || !isReaderGenerationCurrent("navigation", generation)) return;
const marker = viewport.getBoundingClientRect().top + 8,
offset = article.getBoundingClientRect().height * (position - index);
foliateScrollAnchors.invalidate();
viewport.scrollTop = Math.max(
0,
viewport.scrollTop + article.getBoundingClientRect().top - marker + offset
);
syncFoliateScrollLocation();
updateTocCurrentMark();
updateProgressTools();
scheduleSave();
} catch (error) {
reportNavigationError(error, generation);
}
}
async function renderBookmarks() {
const list = document.querySelector("#bookmarks-list"),
generation = nextReaderGeneration("bookmarks");
try {
const entries = panelState.showingAllBookmarks
? await VoiceOfMLReaderStore.listAllBookmarks()
: await VoiceOfMLReaderStore.listBookmarks(sourceUrl);
if (!isReaderGenerationCurrent("bookmarks", generation)) return;
list.textContent = "";
for (const entry of entries) {
const row = document.createElement("div");
row.className = "panel-item";
row.dataset.bookmarkId = entry.id;
const open = document.createElement("button");
open.type = "button";
open.className = "panel-item-main";
open.textContent = panelState.showingAllBookmarks
? `${entry.title || "未命名书籍"} · ${entry.label}`
: entry.label;
open.addEventListener("click", () =>
entry.url !== sourceUrl
? navigateReader(entry.readerUrl, entry)
: restoreFormat(capability.mode, entry)
);
const excerpt = document.createElement("p");
excerpt.className = "bookmark-excerpt";
excerpt.textContent = entry.excerpt || "";
excerpt.hidden = !entry.excerpt;
const meta = document.createElement("small");
meta.textContent = new Date(entry.createdAt).toLocaleString();
const remove = document.createElement("button");
remove.type = "button";
remove.className = "panel-item-remove";
remove.textContent = "删除";
remove.addEventListener("click", async () => {
await VoiceOfMLReaderStore.removeBookmark(entry.id);
row.remove();
if (!list.querySelector(".panel-item")) emptyPanel(list, "暂无书签");
});
const edit = document.createElement("button");
edit.type = "button";
edit.className = "panel-item-edit";
edit.textContent = "编辑";
edit.addEventListener("click", () => openBookmarkPopover(edit, entry));
row.append(open, edit, remove, excerpt, meta);
list.appendChild(row);
}
if (!list.childElementCount) emptyPanel(list, "暂无书签");
filterPanel(document.querySelector("#bookmarks-panel"));
} catch (_) {
if (isReaderGenerationCurrent("bookmarks", generation)) emptyPanel(list, "无法读取书签");
}
}
function pageAtMarker() {
const y = bookmarkRibbon.getBoundingClientRect().bottom,
pages = [...content.querySelectorAll(".reader-page, .reader-docx-page")];
if (!pages.length) return null;
return (
pages.find((page) => {
const rect = page.getBoundingClientRect();
return rect.top <= y && rect.bottom > y;
}) ||
pages.find((page) => page.getBoundingClientRect().bottom > y) ||
pages[pages.length - 1]
);
}
function syncCurrentPageFromMarker() {
if (performance.now() < pageNavigationLockUntil) return;
const page = pageAtMarker();
if (!page) return;
const next = Number(page.dataset.page);
if (!next || next === documentState.page) return;
updateDocumentState({ page: next });
if (document.activeElement !== pageInput) pageInput.value = String(next);
updateTocCurrentMark();
updateProgressTools();
}
function scheduleMarkerSync() {
if (markerFrame) return;
markerFrame = requestAnimationFrame(() => {
markerFrame = 0;
if (readerAbortController.signal.aborted) return;
syncCurrentPageFromMarker();
syncHeadingLocation();
updateProgressTools();
scheduleSave();
});
}
function formatMediaTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return "00:00";
const total = Math.floor(seconds),
hours = Math.floor(total / 3600),
minutes = Math.floor((total % 3600) / 60),
secs = total % 60;
return `${hours ? `${String(hours).padStart(2, "0")}:` : ""}${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
function captureBookmarkSnapshot() {
if (mediaElement) {
const time = Number.isFinite(mediaElement.currentTime) ? mediaElement.currentTime : 0;
return {
locator: `media:${Math.round(time * 10) / 10}`,
label: `时间 ${formatMediaTime(time)}`,
excerpt: "",
progress: mediaElement.duration ? Math.round((time / mediaElement.duration) * 1000) / 10 : 0,
mediaTime: time,
page: 0,
pageOffset: 0,
scrollTop: 0,
htmlScrollTop: 0
};
}
syncCurrentPageFromMarker();
const foliatePosition = captureFoliateBookmarkPosition() || captureChapterPosition();
const shell = documentState.pageCount ? pageAtMarker() : null;
const pageOffset = shell ? Math.max(0, viewport.scrollTop - shell.offsetTop) : 0;
const progress = readerProgressPercent();
const htmlScrollTop = htmlFrame && htmlFrame.contentWindow ? htmlFrame.contentWindow.scrollY : 0;
const scrollTop = viewport.scrollTop;
const pagedDocument = documentState.pageCount && !["text", "markdown"].includes(capability.mode);
const locator = Number.isInteger(foliatePosition?.chapterIndex)
? `chapter:${foliatePosition.chapterIndex}:${Math.round(foliatePosition.chapterOffset)}`
: foliatePosition
? `foliate:${foliatePosition.foliateSection}:${Math.round(foliatePosition.foliateOffset)}`
: pagedDocument
? `page:${documentState.page}:${Math.round(pageOffset)}`
: `progress:${progress}:${Math.round(htmlFrame ? htmlScrollTop : scrollTop)}`;
return {
locator,
label: pagedDocument
? `第 ${documentState.page} / ${documentState.pageCount} 页`
: `阅读进度 ${progress.toFixed(1)}%`,
excerpt: bookmarkExcerpt(),
progress,
page: pagedDocument ? documentState.page : 0,
pageOffset,
scrollTop,
htmlScrollTop,
...(foliatePosition || {})
};
}
// Bookmark dialog owns focus and temporarily makes only its siblings inert.
function setBookmarkDialogModal(open) {
document.documentElement.classList.toggle("bookmark-dialog-open", open);
if (open) {
for (let node = bookmarkPopover; node && node !== document.body; node = node.parentElement) {
for (const sibling of node.parentElement.children)
if (sibling !== node && !bookmarkInertSiblings.has(sibling)) {
bookmarkInertSiblings.set(sibling, sibling.inert);
sibling.inert = true;
}
}
} else {
for (const [node, inert] of bookmarkInertSiblings) node.inert = inert;
bookmarkInertSiblings.clear();
}
}
function openBookmarkPopover(invoker = bookmarkRibbon, entry = null) {
if (!bookmarkPopover.hidden) return;
bookmarkInvoker = invoker;
pendingBookmarkSnapshot = entry ? null : captureBookmarkSnapshot();
updatePanelState({ editingBookmark: entry ? { ...entry } : null });
const snapshot = entry || pendingBookmarkSnapshot;
document.querySelector("#bookmark-prompt").textContent = entry
? `编辑书签 · ${entry.label}`
: `在${snapshot.label}添加书签?`;
bookmarkLabelInput.value = snapshot.label || "";
bookmarkExcerptInput.value = snapshot.excerpt || "";
document.querySelector("#bookmark-add").textContent = entry ? "保存书签" : "添加书签";
bookmarkPopover.hidden = false;
invoker.setAttribute("aria-expanded", "true");
invoker.setAttribute("aria-controls", bookmarkPopover.id);
bookmarkLabelInput.focus();
setBookmarkDialogModal(true);
}
function closeBookmarkPopover() {
pendingBookmarkSnapshot = null;
updatePanelState({ editingBookmark: null });
bookmarkPopover.hidden = true;
setBookmarkDialogModal(false);
bookmarkInvoker?.setAttribute("aria-expanded", "false");
(bookmarkInvoker?.isConnected ? bookmarkInvoker : bookmarkRibbon).focus({ preventScroll: true });
bookmarkInvoker = null;
}
bookmarkRibbon.addEventListener("click", () => openBookmarkPopover(bookmarkRibbon));
document.querySelector("#bookmark-cancel").addEventListener("click", closeBookmarkPopover);
document.querySelector("#bookmark-add").addEventListener("click", async (event) => {
const button = event.currentTarget,
editing = panelState.editingBookmark;
const snapshot = pendingBookmarkSnapshot || editing || captureBookmarkSnapshot();
const label = bookmarkLabelInput.value.trim() || snapshot.label,
excerpt = bookmarkExcerptInput.value.trim();
button.disabled = true;
try {
await VoiceOfMLReaderStore.putBookmark(
editing
? { ...editing, label, excerpt }
: {
...snapshot,
id: `${sourceUrl}\0${snapshot.locator}`,
url: sourceUrl,
title: documentState.title,
extension,
readerUrl: currentReaderUrl().href,
label,
excerpt,
createdAt: Date.now()
}
);
const invoker = bookmarkInvoker;
closeBookmarkPopover();
if (!document.querySelector("#bookmarks-panel").hidden) {
await renderBookmarks();
if (editing && invoker && !invoker.isConnected && document.activeElement === document.body)
(
document.querySelector(
`#bookmarks-list [data-bookmark-id="${CSS.escape(editing.id)}"] .panel-item-edit`
) || bookmarksAllButton
).focus();
}
} catch (_) {
document.querySelector("#bookmark-prompt").textContent = "书签保存失败,请重试。";
} finally {
button.disabled = false;
}
});
bookmarkPopover.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
closeBookmarkPopover();
return;
}
if (event.key !== "Tab") return;
const controls = [
...bookmarkPopover.querySelectorAll("input,textarea,select,button,a[href],[tabindex]")
].filter((node) => !node.disabled && node.tabIndex >= 0 && node.getClientRects().length);
const first = controls[0],
last = controls[controls.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
for (const button of document.querySelectorAll(".reader-panel-tabs button"))
button.addEventListener("click", () => selectPanel(button.dataset.panel));
document.querySelector(".reader-panel-tabs").addEventListener("keydown", (event) => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
const tabs = [...document.querySelectorAll(".reader-panel-tabs button:not([hidden])")],
current = tabs.indexOf(document.activeElement);
if (current < 0) return;
event.preventDefault();
const next =
event.key === "Home"
? 0
: event.key === "End"
? tabs.length - 1
: (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
selectPanel(tabs[next].dataset.panel);
tabs[next].focus();
});
bookmarksAllButton.addEventListener("click", () => {
updatePanelState({ showingAllBookmarks: !panelState.showingAllBookmarks });
bookmarksAllButton.textContent = panelState.showingAllBookmarks ? "本书书签" : "全部书签";
bookmarksAllButton.setAttribute("aria-pressed", String(panelState.showingAllBookmarks));
renderBookmarks();
});
for (const button of document.querySelectorAll(".panel-search-toggle")) {
button.classList.remove("icon-button");
button.classList.add("text-action");
button.textContent = "搜索";
button.hidden = false;
button.setAttribute("aria-expanded", "false");
button.addEventListener("click", () =>
setPanelSearchOpen(button, button.getAttribute("aria-expanded") !== "true")
);
}
for (const input of document.querySelectorAll(".panel-search")) {
const view = input.closest(".reader-panel-view");
input.type = "search";
input.setAttribute(
"aria-label",
`搜索${view.querySelector(".panel-view-header strong")?.textContent || "列表"}`
);
input.addEventListener("input", (event) => {
if (!event.isComposing) filterPanel(view);
});
input.addEventListener("compositionend", () => filterPanel(view));
}
document.querySelector("#history-clear").addEventListener("click", async () => {
if (!confirm("清空全部阅读历史?")) return;
historySuppressed = true;
await progressSaveChain;
await VoiceOfMLReaderStore.clearHistory();
renderHistory();
});
readerThemeToggle.addEventListener("click", () => {
const theme = readerTheme === "dark" ? "light" : "dark";
applyReaderTheme(theme);
if (window.parent !== window)
window.parent.postMessage({ type: "voice-reader-theme", theme }, location.origin);
});
window.addEventListener("storage", (event) => {
if (event.key === "theme" && event.newValue) applyReaderTheme(event.newValue, false);
});
window.addEventListener("message", (event) => {
if (
event.origin === location.origin &&
event.source === window.parent &&
event.data?.type === "voice-reader-abort"
) {
saveProgress();
disposeReader();
return;
}
if (
event.origin === location.origin &&
event.source === window.parent &&
event.data &&
event.data.type === "voice-reader-theme-state"
)
applyReaderTheme(event.data.theme, false);
if (event.origin === location.origin && event.source === window.parent &&
event.data?.type === "voice-reader-system-back") {
if (readerUiHistoryState === "full-search") closeFullSearchView();
else if (readerUiHistoryState === "panel") setReaderPanelOpen(false, true);
}
});
document
.querySelector("#history")
.addEventListener("click", () =>
setReaderPanelOpen(document.querySelector("#history").getAttribute("aria-expanded") !== "true")
);
document
.querySelector("#history-close")
.addEventListener("click", () => setReaderPanelOpen(false, true));
viewport.addEventListener("scroll", handleReaderPositionChange, { passive: true });
function notePdfScrollIntent() {
pdfUserHasScrolled = true;
beginReaderNavigation();
}
for (const type of ["wheel", "touchstart", "pointerdown"])
viewport.addEventListener(type, notePdfScrollIntent, { passive: true });
let pdfSelectionPointer = null;
viewport.addEventListener("pointerdown", (event) => {
if (event.pointerType !== "mouse") {
pdfSelectionPointer = null;
return;
}
const run = event.target.closest?.(".reader-pdf-text-run");
const selection = document.getSelection();
const selectedRun = !selection?.isCollapsed && event.target.closest?.(".reader-page")
? selection.anchorNode?.parentElement?.closest(".reader-pdf-text-run") : null;
const origin = run || selectedRun;
pdfSelectionPointer = origin
? { id: event.pointerId, x: event.clientX, y: event.clientY, frame: 0,
page: origin.closest(".reader-page"), anchorNode: run ? null : selection.anchorNode,
anchorOffset: run ? 0 : selection.anchorOffset } : null;
}, { passive: true });
document.addEventListener("pointermove", (event) => {
const start = pdfSelectionPointer;
if (event.pointerType !== "mouse" || !start || start.id !== event.pointerId ||
Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) return;
const selection = document.getSelection();
if (!start.anchorNode && selection?.rangeCount && start.page.contains(selection.anchorNode) &&
selection.anchorNode.parentElement?.closest(".reader-pdf-text-run")) {
start.anchorNode = selection.anchorNode;
start.anchorOffset = selection.anchorOffset;
}
if (start.anchorNode) {
start.endX = event.clientX;
start.endY = event.clientY;
if (!start.frame) start.frame = requestAnimationFrame(() => {
start.frame = 0;
if (pdfSelectionPointer === start)
correctPdfCrossPageSelection(start.endX, start.endY, start);
});
}
}, { passive: true });
document.addEventListener("pointerup", (event) => {
const start = pdfSelectionPointer;
pdfSelectionPointer = null;
if (event.pointerType !== "mouse" || !["pdf", "pdf-pages"].includes(capability.mode) ||
!start || start.id !== event.pointerId ||
Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) return;
const x = event.clientX, y = event.clientY;
requestAnimationFrame(() => correctPdfCrossPageSelection(x, y, start));
}, { passive: true });
document.addEventListener("pointercancel", () => { pdfSelectionPointer = null; }, { passive: true });
let pdfSelectionBoundaryUpdate = false;
document.addEventListener("selectionchange", () => {
if (pdfSelectionBoundaryUpdate ||
!["pdf", "pdf-pages"].includes(capability.mode)) return;
const selection = document.getSelection();
if (!selection || selection.isCollapsed || !selection.rangeCount) return;
const runs = [...content.querySelectorAll(".reader-pdf-text-run")]
.filter((run) => run.firstChild?.nodeType === Node.TEXT_NODE);
if (!runs.length) return;
const anchorRun = selection.anchorNode?.parentElement?.closest(".reader-pdf-text-run");
const focusRun = selection.focusNode?.parentElement?.closest(".reader-pdf-text-run");
if (!anchorRun && !focusRun) return;
if (focusRun) return;
const beforeAnchor = selection.focusNode && selection.anchorNode &&
(selection.focusNode === selection.anchorNode
? selection.focusOffset < selection.anchorOffset
: Boolean(selection.focusNode.compareDocumentPosition(selection.anchorNode) & Node.DOCUMENT_POSITION_FOLLOWING));
const target = beforeAnchor ? runs[0] : runs[runs.length - 1];
const node = target.firstChild;
pdfSelectionBoundaryUpdate = true;
try {
selection.setBaseAndExtent(selection.anchorNode, selection.anchorOffset, node,
beforeAnchor ? 0 : node.length);
} finally {
pdfSelectionBoundaryUpdate = false;
}
}, { passive: true });
window.addEventListener("pagehide", (event) => {
saveProgress(event);
if (!event.persisted) disposeReader();
});
window.addEventListener("pageshow", (event) => {
if (event.persisted && !readerLifecycle.disposed) {
scheduleFoliateScrollSync();
updateProgressTools();
}
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") saveProgress();
if (document.hidden) stopPdfManifestPrefetch();
else queuePdfManifestPrefetch();
});
window.addEventListener("offline", stopPdfManifestPrefetch);
window.addEventListener("online", () => queuePdfManifestPrefetch());
function validSource(raw) {
try {
const url = new URL(raw);
const bucketPath = url.searchParams.get("path") || "";
if (
url.origin === "https://voiceofml-search.hf.space" &&
url.pathname === "/api/reader-bucket-resource" &&
url.searchParams.getAll("path").length === 1 &&
[...url.searchParams.keys()].every((key) => key === "path") &&
VoiceOfMLReader.isBucketPath(bucketPath, true, "vomebook/pdf-pages")
)
return true;
if (url.protocol === "https:" && url.hostname === "huggingface.co" &&
url.pathname.startsWith("/buckets/vomebook/pdf-pages/resolve/") && !url.search && !url.hash) {
const prefix = "/buckets/vomebook/pdf-pages/resolve/";
const path = decodeURIComponent(url.pathname.slice(prefix.length));
return VoiceOfMLReader.isBucketPath(path, true, "vomebook/pdf-pages");
}
if (url.protocol !== "https:" || !["huggingface.co", "hf-mirror.com"].includes(url.hostname))
return false;
const readerAsset = VoiceOfMLReader.isAssetSourcePath(url.pathname);
if (extension === "docx") return readerAsset;
return /^\/datasets\/VoiceOfML\/[^/]+\/(resolve|raw)\//.test(url.pathname) || readerAsset;
} catch (_) {
return false;
}
}
function validOcr(raw) {
try {
const url = new URL(raw, location.origin);
return url.origin === location.origin && url.pathname.startsWith("/txt/");
} catch (_) {
return false;
}
}
function validFallback(raw) {
try {
const url = new URL(raw);
return (
url.protocol === "https:" &&
["huggingface.co", "hf-mirror.com"].includes(url.hostname) &&
/\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:linearized\.pdf|(?:[a-z0-9-]+\/)?document\.pdf)$/.test(
url.pathname
)
);
} catch (_) {
return false;
}
}
function readerAssetObjectFamily(raw, base = location.href) {
try {
const url = new URL(raw, base);
if (url.origin === "https://voiceofml-search.hf.space" && url.pathname === "/api/reader-bucket-resource") {
const bucketPath = url.searchParams.get("path") || "";
const match = bucketPath.match(/^(ebook-chapters\/objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/[a-z0-9-]+\/[a-z0-9-]+-epub-chapters-v[0-9]+-bucket\/epub-chapters\/)/);
if (url.hash || url.searchParams.getAll("path").length !== 1 ||
[...url.searchParams.keys()].some((key) => key !== "path") || !match ||
!VoiceOfMLReader.isBucketPath(bucketPath, true)) return null;
return { url, prefix: match[1], bucket: true, bucketPath };
}
if (
url.protocol !== "https:" ||
!["huggingface.co", "hf-mirror.com"].includes(url.hostname) ||
url.username ||
url.password
)
return null;
const match = url.pathname.match(
/^\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/(objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[a-z0-9-]+\/)?)/
);
return match
? { url, prefix: url.pathname.slice(0, url.pathname.indexOf(match[1])) + match[1], bucket: false }
: null;
} catch (_) {
return null;
}
}
function trustedReaderAssetManifestUrl(raw) {
const family = readerAssetObjectFamily(raw);
const manifestPath = family?.bucket ? family.bucketPath : family?.url.pathname;
return family &&
(family.bucket || !family.url.search) &&
(family.bucket || !family.url.hash) &&
/\/chapter-manifest\.json$/i.test(manifestPath || "")
? family.url.href
: null;
}
function trustedChapterUrl(raw, base) {
const manifest = readerAssetObjectFamily(base);
const manifestPath = manifest?.bucket ? manifest.bucketPath : new URL(base, location.href).pathname;
if (!manifest || !/\/chapter-manifest\.json$/i.test(manifestPath))
return null;
try {
if (manifest.bucket) {
const url = new URL(String(raw || ""), `https://bucket.invalid/${manifest.bucketPath}`);
const path = decodeURIComponent(url.pathname.slice(1));
if (url.origin !== "https://bucket.invalid" || url.search || url.hash ||
!path.startsWith(manifest.prefix) ||
!/(?:\/chapters\/chapter-[0-9]{4}\.xhtml|\/epub-search-index\.json\.gz)$/i.test(path) ||
!VoiceOfMLReader.isBucketPath(path, true)) return null;
return `${manifest.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`;
}
const url = new URL(raw, base);
return !url.search &&
!url.hash &&
url.origin === manifest.url.origin &&
url.pathname.startsWith(manifest.prefix) &&
/(?:\.(?:xhtml?|html?)|\/epub-search-index\.json\.gz)$/i.test(url.pathname)
? url.href
: null;
} catch (_) {
return null;
}
}
function trustedChapterReferenceUrl(raw, base, manifestBase) {
const family = readerAssetObjectFamily(manifestBase);
if (!family) return null;
try {
if (family.bucket) {
const current = readerAssetObjectFamily(base);
if (!current?.bucket) return null;
const url = new URL(String(raw || ""), `https://bucket.invalid/${current.bucketPath}`);
const path = decodeURIComponent(url.pathname.slice(1));
if (url.origin !== "https://bucket.invalid" || url.search ||
!path.startsWith(family.prefix) || !/\/chapters\/chapter-[0-9]{4}\.xhtml$/i.test(path) ||
!VoiceOfMLReader.isBucketPath(path, true)) return null;
return { url: `${family.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`,
fragment: url.hash };
}
const url = new URL(raw, base);
return !url.search && url.origin === family.url.origin && url.pathname.startsWith(family.prefix) &&
/\/(?:chapters\/)?[^/]+\.xhtml?$/i.test(url.pathname)
? { url: url.href, fragment: url.hash } : null;
} catch (_) {
return null;
}
}
function trustedChapterResourceUrl(raw, base, attribute, manifestBase) {
const value = String(raw || "").trim();
if (!value || /^(?:data:|mailto:|tel:|javascript:)/i.test(value)) return null;
// Chapters and resources can be siblings within the manifest's asset directory.
const family = readerAssetObjectFamily(manifestBase);
if (!family) return null;
try {
if (family.bucket) {
const current = readerAssetObjectFamily(base);
if (!current?.bucket) return null;
const url = new URL(value, `https://bucket.invalid/${current.bucketPath}`);
const path = decodeURIComponent(url.pathname.slice(1));
if (url.origin !== "https://bucket.invalid" || url.search || url.hash ||
!path.startsWith(family.prefix) || !VoiceOfMLReader.isBucketPath(path, true)) return null;
const resource = `${family.url.origin}/api/reader-bucket-resource?path=${encodeURIComponent(path)}`;
return attribute === "href" ? resource : resource;
}
const url = new URL(value, base),
path = url.pathname.toLowerCase();
if (url.origin !== family.url.origin || !url.pathname.startsWith(family.prefix)) return null;
const allowed =
attribute === "href"
? /\.(?:x?html?|css|svg|png|jpe?g|gif|webp|woff2?|ttf|otf|mp3|mp4|m4a|webm|ogg)$/i.test(
path
)
: /\.(?:svg|png|jpe?g|gif|webp|woff2?|ttf|otf|mp3|mp4|m4a|webm|ogg)$/i.test(path);
return allowed ? url.href : null;
} catch (_) {
return null;
}
}
function loadScript(url) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = url;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
function fetchWithReaderTimeout(url, timeoutMs = READER_PROXY_TIMEOUT_MS, requestInit = {}) {
return readerRequestManager.request(url, timeoutMs, requestInit);
}
function retryableReaderProxyError(error) {
return [408, 429, 500, 502, 503, 504].includes(error?.status) ||
["TypeError", "TimeoutError"].includes(error?.name) ||
/failed to fetch|network|timeout|HTTP\s*(?:408|429|5\d\d)|server response\s*\((?:408|429|5\d\d)\)/i.test(error?.message || "");
}
async function retryReaderProxy(open) {
for (let attempt = 0; ; attempt++) {
assertReaderActive();
try {
return await open();
} catch (error) {
if (attempt >= 2 || !retryableReaderProxyError(error)) throw error;
await waitForReader(350 * (attempt + 1));
}
}
}
function fetchReaderUrl(rawUrl, requestInit = {}) {
if (String(rawUrl).includes("/api/reader-bucket-resource?"))
return fetchWithReaderTimeout(rawUrl, READER_PROXY_TIMEOUT_MS, requestInit);
try {
const url = new URL(rawUrl, location.href);
if (url.protocol === "https:" && url.hostname === "huggingface.co" &&
url.pathname.startsWith("/buckets/vomebook/pdf-pages/resolve/") && !url.search && !url.hash)
return fetchWithReaderTimeout(url.href, READER_PROXY_TIMEOUT_MS, requestInit);
} catch (_) {}
const proxyUrl = readerContentUrl(rawUrl);
return retryReaderProxy(async () => {
const response = await fetchWithReaderTimeout(proxyUrl, READER_PROXY_TIMEOUT_MS, requestInit);
if (response.ok) return response;
await response.body?.cancel();
throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status });
}).catch((error) => {
assertReaderActive();
if (retryableReaderProxyError(error)) throw error;
return fetchWithReaderTimeout(rawUrl, READER_PROXY_TIMEOUT_MS, requestInit);
});
}
function fetchReaderResponse() {
const early = window.__VOICE_PDF_PRELOAD__;
if (early?.manifest && early.manifestUrl === new URL(sourceUrl, location.href).href) {
const pending = early.manifest;
early.manifest = null;
return pending.then(
(response) => (response?.ok ? response : fetchReaderUrl(sourceUrl)),
() => fetchReaderUrl(sourceUrl)
);
}
return fetchReaderUrl(sourceUrl);
}
async function preloadPdfFirstPage(total) {
await getInitialReaderRestoration();
try {
const source = pdfManifestSource();
if (!source) return;
const page = initialReaderPage(total);
pdfFirstPagePreloadPage = page;
const pageUrl = new URL(source.pageUrl(page));
const early = window.__VOICE_PDF_PRELOAD__;
if (early?.image && early.pageUrl === pageUrl.href) {
pdfFirstPagePreload = early.image;
// Reuse an existing request at demand priority when available.
pdfFirstPagePreload.fetchPriority = "high";
}
else if (!navigator.connection?.saveData) {
pdfFirstPagePreload = new Image();
pdfFirstPagePreload.decoding = "async";
pdfFirstPagePreload.fetchPriority = "high";
pdfFirstPagePreload.src = pageUrl.href;
}
} catch (_) {}
}
function pdfManifestSource() {
return VoiceOfMLReader.pdfPageSource(sourceUrl, location.href, location.origin);
}
function pdfPageEntry(page) {
if (!pdfPageManifest || page < 1 || page > pdfPageManifest.pageCount) return null;
const pageEntry = (
pdfPageManifest.entries?.[page - 1] || {
page,
path: `${pdfPageManifest.root}/pages/page-${String(page).padStart(6, "0")}.webp`
}
);
const ocrEntry = pdfPageManifest.ocr?.[page - 1];
return ocrEntry ? { ...pageEntry, ...ocrEntry, page } : pageEntry;
}
function takePdfManifestPrefetch(target) {
const record = pdfManifestPrefetches.get(target);
if (!record) return null;
pdfManifestPrefetches.delete(target);
pdfManifestPrefetchActive.delete(record);
clearTimeout(record.timeout);
record.image.onload = record.image.onerror = null;
return record.image;
}
function queuePdfManifestPrefetch(delay = 250) {
if (!pdfManifestPrefetchOrigin || !canPrefetchPdfImages()) return;
clearTimeout(pdfManifestPrefetchTimer);
pdfManifestPrefetchTimer = setTimeout(() => {
pdfManifestPrefetchTimer = 0;
if (readerAbortController.signal.aborted || document.hidden || !navigator.onLine ||
pdfManifestPrefetchActive.size >= pdfManifestPrefetchConcurrency() ||
pdfManifestPrefetchCount >= pdfManifestPrefetchMaxPages() ||
(pdfActiveRenders && content.querySelector('.reader-page[data-render-visible="1"][data-render-state="rendering"]'))) return;
while (pdfManifestPrefetchActive.size < pdfManifestPrefetchConcurrency()) {
if (pdfManifestPrefetchCount >= pdfManifestPrefetchMaxPages()) return;
if (pdfManifestPrefetchWrapped && pdfManifestPrefetchNext >= pdfManifestPrefetchOrigin) return;
if (pdfManifestPrefetchNext > documentState.pageCount) {
pdfManifestPrefetchNext = 1;
pdfManifestPrefetchWrapped = true;
}
const next = pdfManifestPrefetchNext++;
const shell = pdfManifestShells[next];
if (shell && ["rendering", "rendered"].includes(shell.dataset.renderState)) continue;
const url = pdfPageManifest.pageUrl(next);
if (pdfManifestPrefetches.has(url)) continue;
const image = new Image();
const record = { image, url, timeout: 0 };
image.decoding = "async";
image.fetchPriority = "low";
const finish = (failed) => {
if (!pdfManifestPrefetchActive.has(record)) return;
pdfManifestPrefetchActive.delete(record);
clearTimeout(record.timeout);
image.onload = image.onerror = null;
if (failed) {
pdfManifestPrefetches.delete(url);
image.removeAttribute("src");
}
queuePdfManifestPrefetch();
};
image.onload = () => finish(false);
image.onerror = () => finish(true);
pdfManifestPrefetches.set(url, record);
pdfManifestPrefetchActive.add(record);
pdfManifestPrefetchCount++;
record.timeout = setTimeout(() => finish(true), 15000);
image.src = url;
trimPdfManifestPrefetches();
}
}, delay);
}
function prioritizePdfManifestPrefetch(target) {
for (const record of [...pdfManifestPrefetchActive]) {
if (record.url !== target) cancelPdfManifestPrefetch(record);
}
const record = pdfManifestPrefetches.get(target);
if (record && pdfManifestPrefetchActive.has(record)) record.image.fetchPriority = "high";
}
function schedulePdfManifestPrefetch(page) {
if (capability.mode !== "pdf-pages" || !pdfPageManifest) return;
const origin = Math.max(1, Math.min(documentState.pageCount + 1, page + 1));
if (pdfManifestPrefetchOrigin === origin && pdfManifestPrefetchNext) {
if (!pdfManifestPrefetchActive.size && !pdfManifestPrefetchTimer) queuePdfManifestPrefetch();
return;
}
stopPdfManifestPrefetch();
pdfManifestPrefetchOrigin = pdfManifestPrefetchNext = origin;
pdfManifestPrefetchWrapped = false;
queuePdfManifestPrefetch();
}
function pdfOcrSource() {
return VoiceOfMLReader.pdfPageSource(ocrManifestUrl, location.href, location.origin, "ocr-manifest.json");
}
async function readPdfOcrJson(url, limit = VoiceOfMLReaderSecurity.LIMITS.chapterBytes, digest = "", size = 0, priority = "auto") {
const response = await fetchReaderUrl(url, { priority });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
let bytes = await VoiceOfMLReaderSecurity.readBytes(response, limit);
if (size && bytes.byteLength !== size) throw new Error("PDF_OCR_SIZE_MISMATCH");
if (digest) {
const actual = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))]
.map((value) => value.toString(16).padStart(2, "0")).join("");
if (actual !== digest) throw new Error("PDF_OCR_HASH_MISMATCH");
}
const parsed = new URL(url, location.href);
if (/\.gz$/i.test(parsed.searchParams.get("path") || parsed.pathname)) {
if (typeof DecompressionStream !== "function") throw new Error("PDF_OCR_GZIP_UNSUPPORTED");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
bytes = await VoiceOfMLReaderSecurity.readBytes(new Response(stream), limit);
}
return JSON.parse(new TextDecoder().decode(bytes));
}
async function loadPdfOcrManifest() {
if (pdfOcrManifest) return pdfOcrManifest;
if (!ocrManifestUrl) return null;
if (!pdfOcrManifestPromise) {
if (!pdfOcrSource()) throw new Error("PDF_OCR_SOURCE_INVALID");
pdfOcrManifestPromise = readPdfOcrJson(ocrManifestUrl, VoiceOfMLReaderSecurity.LIMITS.manifestBytes, "", 0, "high").then((manifest) => {
assertReaderActive();
if (manifest.page_count !== documentState.pageCount) throw new Error("PDF_OCR_PAGE_COUNT_MISMATCH");
pdfOcrManifest = VoiceOfMLReaderSecurity.validatePdfOcrManifest(manifest);
return pdfOcrManifest;
});
}
try { return await pdfOcrManifestPromise; }
catch (error) { pdfOcrManifestPromise = null; throw error; }
}
function loadPdfOcrPage(page, priority = "high") {
const cached = cachedPdfOcrPage(page);
if (cached) return Promise.resolve(cached);
const pending = pdfOcrPagePromises.get(page);
if (pending) return pending;
const task = Promise.resolve().then(() => {
// OCR is published as its own manifest. The image page manifest may be
// intentionally lean and contain only page_count.
return loadPdfOcrManifest().then((manifest) => {
const entry = manifest?.pages?.[page - 1];
if (!entry || entry.p !== page) throw new Error("PDF_OCR_PAGE_MISSING");
return readPdfOcrJson(pdfOcrSource().assetUrl(entry.o),
VoiceOfMLReaderSecurity.LIMITS.chapterBytes, entry.os, entry.ob, priority).then((payload) => {
VoiceOfMLReaderSecurity.validatePdfOcrPage(payload, page);
cachePdfOcrPage(page, payload);
return payload;
});
});
}).finally(() => pdfOcrPagePromises.delete(page));
pdfOcrPagePromises.set(page, task);
return task;
}
function estimatePdfTextContentBytes(content) {
let bytes = 0;
for (const item of content?.items || [])
bytes += String(item.str || "").length * 2 + (item.transform?.length || 0) * 8 + 64;
for (const style of Object.values(content?.styles || {}))
bytes += Object.entries(style || {}).reduce(
(total, [key, value]) => total + key.length + String(value).length * 2 + 16, 0
);
return Math.max(128, bytes);
}
function cachePdfTextContent(page, content) {
const bytes = estimatePdfTextContentBytes(content);
if (bytes > PDF_TEXT_CONTENT_CACHE_BYTES) return;
const previous = pdfTextContentCache.get(page);
if (previous) pdfTextContentCacheBytes -= previous.bytes;
pdfTextContentCache.delete(page);
pdfTextContentCache.set(page, { content, bytes });
pdfTextContentCacheBytes += bytes;
while (pdfTextContentCacheBytes > PDF_TEXT_CONTENT_CACHE_BYTES) {
const oldest = pdfTextContentCache.keys().next().value;
const removed = pdfTextContentCache.get(oldest);
pdfTextContentCacheBytes -= removed.bytes;
pdfTextContentCache.delete(oldest);
}
}
function cachedPdfTextContent(page) {
const record = pdfTextContentCache.get(page);
if (!record) return null;
pdfTextContentCache.delete(page);
pdfTextContentCache.set(page, record);
return record.content;
}
function estimatePdfOcrPageBytes(payload) {
return Math.max(128, (payload?.blocks || []).reduce(
(total, block) => total + String(block?.t || "").length * 2 + 48, 0
));
}
function cachePdfOcrPage(page, payload) {
const bytes = estimatePdfOcrPageBytes(payload);
if (bytes > PDF_OCR_PAGE_CACHE_BYTES) return;
const previous = pdfOcrPageCache.get(page);
if (previous) pdfOcrPageCacheBytes -= previous.bytes;
pdfOcrPageCache.delete(page);
pdfOcrPageCache.set(page, { payload, bytes });
pdfOcrPageCacheBytes += bytes;
while (pdfOcrPageCacheBytes > PDF_OCR_PAGE_CACHE_BYTES) {
const oldest = pdfOcrPageCache.keys().next().value;
const removed = pdfOcrPageCache.get(oldest);
pdfOcrPageCacheBytes -= removed.bytes;
pdfOcrPageCache.delete(oldest);
}
}
function cachedPdfOcrPage(page) {
const record = pdfOcrPageCache.get(page);
if (!record) return null;
pdfOcrPageCache.delete(page);
pdfOcrPageCache.set(page, record);
return record.payload;
}
async function renderPdfOcrText(shell) {
if (shell.dataset.textReady === "1" || !pdfPageManifest || !ocrManifestUrl) return;
if (shell._ocrPromise) return shell._ocrPromise;
const layer = shell.querySelector(".reader-pdf-text");
if (!layer) return;
const epoch = shell._textEpoch || 0;
const task = (async () => {
try {
const page = Number(shell.dataset.page);
await acquirePdfTextSlot(isPdfPageVisible(shell));
try {
if (epoch !== (shell._textEpoch || 0)) return;
const payload = await loadPdfOcrPage(page, isPdfPageVisible(shell) ? "high" : "low");
assertReaderActive();
if (epoch !== (shell._textEpoch || 0)) return;
VoiceOfMLReaderSecurity.validatePdfOcrPage(payload, page);
populateOcrTextLayer(layer, payload.blocks, payload.layout);
shell._bookmarkTextItems = payload.blocks.map((block) => ({ text: block.t, y: block.b?.[1] || 0 }));
shell.dataset.textReady = "1";
highlightPdfText(shell);
} finally {
releasePdfTextSlot();
}
} catch (error) {
if (!readerAbortController.signal.aborted)
console.warn(`PDF page ${shell.dataset.page} OCR text failed`, error);
}
})().finally(() => { if (shell._ocrPromise === task) delete shell._ocrPromise; });
shell._ocrPromise = task;
return task;
}
function trimPdfManifestImages(protectedShell) {
const images = [...content.querySelectorAll('.reader-page[data-render-state="rendered"]')];
if (images.length <= 25) return;
images.sort(
(a, b) =>
Number(b === protectedShell) - Number(a === protectedShell) ||
Math.abs(Number(a.dataset.page) - documentState.page) -
Math.abs(Number(b.dataset.page) - documentState.page)
);
for (const shell of images.slice(25)) {
if (pdfSelectionIntersects(shell)) continue;
shell._textEpoch = (shell._textEpoch || 0) + 1;
delete shell._ocrPromise;
shell.querySelector("img")?.remove();
const textLayer = shell.querySelector(".reader-pdf-text");
if (textLayer) textLayer.replaceChildren();
shell._bookmarkTextItems = [];
shell.dataset.textReady = "0";
fullSearchActiveMarks = fullSearchActiveMarks.filter((mark) => mark.isConnected);
shell.dataset.renderState = "idle";
}
}
const EPUB_HTML_TAGS = new Set(
"a,abbr,address,area,article,aside,audio,b,base,bdi,bdo,blockquote,body,br,button,canvas,caption,cite,code,col,colgroup,data,datalist,dd,del,details,dfn,dialog,div,dl,dt,em,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,i,iframe,img,input,ins,kbd,label,legend,li,link,main,map,mark,menu,meta,meter,nav,noscript,object,ol,optgroup,option,output,p,picture,pre,progress,q,rb,rp,rt,rtc,ruby,s,samp,script,search,section,select,slot,small,source,span,strong,style,sub,summary,sup,table,tbody,td,template,textarea,tfoot,th,thead,time,title,tr,track,u,ul,var,video,wbr".split(
","
)
);
const EPUB_HTML_VOID_TAGS = new Set(
"area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr".split(",")
);
function normalizeEpubDocument(doc) {
// Legacy CHM conversions can use an XHTML prefix even in HTML content.
for (const element of [...doc.querySelectorAll("*")]) {
const tagName = String(element.tagName || "").toLowerCase();
const localName = tagName.includes(":") ? tagName.slice(tagName.lastIndexOf(":") + 1) : tagName;
if (tagName === localName || !EPUB_HTML_TAGS.has(localName)) continue;
const replacement = doc.createElementNS("http://www.w3.org/1999/xhtml", localName);
for (const attribute of [...element.attributes]) {
if (!attribute.name.toLowerCase().startsWith("xmlns:"))
replacement.setAttribute(attribute.name, attribute.value);
}
// HTML parsing treats prefixed void tags as containers. Keep the following
// content as siblings: serializing real <br>/<hr>/<img> drops their children.
// Move the existing text nodes so Foliate's source/CFI mapping stays intact.
const fragment = doc.createDocumentFragment();
fragment.appendChild(replacement);
if (EPUB_HTML_VOID_TAGS.has(localName)) {
while (element.firstChild) fragment.appendChild(element.firstChild);
} else {
while (element.firstChild) replacement.appendChild(element.firstChild);
}
element.replaceWith(fragment);
}
return doc;
}
function sanitizeReaderDocument(doc, { preserveEpubStyles = false } = {}) {
const blocked = preserveEpubStyles
? "script,iframe,object,embed,base,form"
: "script,iframe,object,embed,base,form,link,style";
for (const node of doc.querySelectorAll(blocked)) node.remove();
if (preserveEpubStyles) {
for (const link of doc.querySelectorAll("link")) {
const rel = (link.getAttribute("rel") || "").toLowerCase().split(/\s+/);
if (!rel.includes("stylesheet") || !/^blob:/i.test(link.getAttribute("href") || ""))
link.remove();
}
for (const style of doc.querySelectorAll("style")) {
if (
/(?:@import\s+|url\s*\(\s*[\"']?)(?:https?:|\/\/|javascript:)/i.test(
style.textContent || ""
)
)
style.remove();
}
}
for (const element of doc.querySelectorAll("*"))
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase(),
value = String(attribute.value || "").trim();
if (
name.startsWith("on") ||
name === "srcdoc" ||
((name === "href" || name === "xlink:href" || name === "src") &&
/^javascript:/i.test(value))
)
element.removeAttribute(attribute.name);
}
return doc;
}
function sanitizeEpubDocument(doc) {
return sanitizeReaderDocument(normalizeEpubDocument(doc), { preserveEpubStyles: true });
}
function parseChapterDocument(text) {
// Parse generated XHTML before HTML's void-element rules can swallow children
// of prefixed elements such as <html:meta/> (including the following title).
const xml = new DOMParser().parseFromString(text, "application/xhtml+xml");
if (!xml.querySelector("parsererror"))
text = new XMLSerializer().serializeToString(normalizeEpubDocument(xml));
return sanitizeEpubDocument(new DOMParser().parseFromString(text, "text/html"));
}
function epubContentBody(doc) {
const nestedBody = [...doc.querySelectorAll("body")].find((body) => body !== doc.body);
return nestedBody || doc.body || doc.documentElement;
}
function disposeReader() {
if (readerLifecycle.disposed) return;
updateDocumentState({ restorationReady: false });
readerRuntime.dispose();
contentSanitizerObserver?.disconnect();
contentSanitizerObserver = null;
clearTimeout(saveTimer);
clearTimeout(themeAnimationTimer);
clearTimeout(panelAnimationTimer);
for (const frame of [markerFrame, foliateScrollFrame, foliateWindowFrame, epubSeekFrame])
if (frame) cancelAnimationFrame(frame);
saveTimer =
themeAnimationTimer =
panelAnimationTimer =
markerFrame =
foliateScrollFrame =
foliateWindowFrame =
epubSeekFrame =
0;
VoiceOfMLReaderStore.dispose?.();
}
function fail(message, code = "READER_PARSE") {
if (readerLifecycle.disposed || readerAbortController.signal.aborted) return;
updateDocumentState({ restorationReady: false });
readerRuntime.fail(code);
stopReaderWork();
readerRequestManager.dispose();
formatAdapters.dispose();
window.__VOICE_PDF_PRELOAD__?.dispose();
content.dataset.errorCode = code;
loadingStatus.hidden = true;
markReaderContentReady();
const visibleMessage = `${message} [${readerLifecycle.stage}]`;
content.innerHTML = `<div class="reader-error"></div>`;
content.querySelector(".reader-error").textContent = visibleMessage;
if (["READER_ENGINE_NETWORK", "READER_RESTORE"].includes(code)) {
const retry = document.createElement("button");
retry.type = "button";
retry.className = "text-button";
retry.id = "reader-engine-retry";
retry.textContent = "重试加载";
// Reload clears the browser's failed module map and preserves this book URL.
retry.addEventListener("click", () => location.reload());
content.querySelector(".reader-error").append(document.createElement("br"), retry);
}
status.textContent = "无法打开";
}
async function appendShellsBatched(createShell, total, batchSize) {
for (let start = 2; start <= total; start += batchSize) {
assertReaderActive();
const fragment = document.createDocumentFragment();
for (let page = start; page < Math.min(start + batchSize, total + 1); page++)
fragment.appendChild(createShell(page));
content.appendChild(fragment);
await waitForReader();
}
}
function pdfSelectionIntersects(element) {
const selection = document.getSelection();
if (!selection?.rangeCount) return false;
if (element.contains(selection.anchorNode) || element.contains(selection.focusNode)) return true;
return !selection.isCollapsed && selection.getRangeAt(0).intersectsNode(element);
}
function correctPdfCrossPageSelection(x, y, start = null) {
const selection = document.getSelection();
if (!selection?.rangeCount) return;
const anchorNode = start?.anchorNode || selection.anchorNode;
const anchorOffset = start?.anchorNode ? start.anchorOffset : selection.anchorOffset;
if (!start?.anchorNode && selection.isCollapsed) return;
const origin = anchorNode?.parentElement?.closest(".reader-page");
let target = document.elementFromPoint(x, y)?.closest(".reader-page");
if (!target) {
let distance = Infinity;
for (const shell of content.querySelectorAll(".reader-page")) {
const rect = shell.getBoundingClientRect();
const gap = Math.max(rect.top - y, y - rect.bottom, 0);
if (gap < distance) { distance = gap; target = shell; }
}
}
if (!origin || !target || origin === target || !content.contains(origin) || !content.contains(target)) return;
const runs = [...target.querySelectorAll(".reader-pdf-text-run")].filter((run) => run.firstChild?.nodeType === Node.TEXT_NODE);
if (!runs.length) return;
let nearest = null, distance = Infinity;
for (const run of runs) {
const rect = run.getBoundingClientRect();
const dx = Math.max(rect.left - x, x - rect.right, 0);
const dy = Math.max(rect.top - y, y - rect.bottom, 0);
const gap = Math.hypot(dx, dy);
if (gap < distance) { nearest = run; distance = gap; }
}
const node = nearest.firstChild, rect = nearest.getBoundingClientRect();
let offset;
if (y < rect.top || y > rect.bottom) {
offset = y < rect.top ? 0 : node.length;
} else {
const range = document.createRange();
const vertical = getComputedStyle(nearest).writingMode.startsWith("vertical");
const position = vertical ? y : x;
let low = 0, high = node.length;
while (low < high) {
const mid = Math.floor((low + high) / 2);
range.setStart(node, mid);
range.setEnd(node, mid + 1);
const glyph = range.getBoundingClientRect();
const center = vertical ? (glyph.top + glyph.bottom) / 2 : (glyph.left + glyph.right) / 2;
if (center < position) low = mid + 1;
else high = mid;
}
offset = low;
}
selection.setBaseAndExtent(anchorNode, anchorOffset, node, offset);
}
function createPdfShellWindow(total, firstShell, createShell, unobserve, defaultRatio) {
if (total <= 256) return null;
const chunkSize = 32, chunks = [], ratios = new Map();
let frame = 0, disposed = false;
const chunkHeight = (chunk) => {
const style = getComputedStyle(content);
const width = (content.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)) * documentState.zoom;
let height = 0;
for (let page = chunk.start; page <= chunk.end; page++)
height += Math.max(160, width * (ratios.get(page) || defaultRatio)) + 18;
return height;
};
const fragment = document.createDocumentFragment();
for (let start = 1; start <= total; start += chunkSize) {
const element = document.createElement("div");
element.className = "reader-pdf-chunk";
element.setAttribute("aria-hidden", "true");
const chunk = { element, start, end: Math.min(total, start + chunkSize - 1), mounted: false };
chunks.push(chunk);
element.style.height = `${chunkHeight(chunk)}px`;
fragment.appendChild(element);
}
content.appendChild(fragment);
function mount(chunk) {
if (chunk.mounted) return;
const pages = document.createDocumentFragment();
for (let page = chunk.start; page <= chunk.end; page++) {
const shell = page === 1 && firstShell.isConnected ? firstShell : createShell(page);
if (ratios.has(page)) shell.style.aspectRatio = `1 / ${ratios.get(page)}`;
pages.appendChild(shell);
}
chunk.element.style.height = "";
chunk.element.removeAttribute("aria-hidden");
chunk.element.appendChild(pages);
chunk.mounted = true;
}
function unmount(chunk) {
if (!chunk.mounted || chunk.element.contains(document.activeElement) ||
pdfSelectionIntersects(chunk.element)) return;
for (const shell of chunk.element.children) {
const page = Number(shell.dataset.page), bounds = shell.getBoundingClientRect();
if (bounds.width && bounds.height) ratios.set(page, bounds.height / bounds.width);
shell._textEpoch = (shell._textEpoch || 0) + 1;
shell._renderCancel?.();
unobserve(shell);
pdfManifestShells[page] = undefined;
}
chunk.element.replaceChildren();
chunk.element.style.height = `${chunkHeight(chunk)}px`;
chunk.element.setAttribute("aria-hidden", "true");
chunk.mounted = false;
}
function show(index) {
if (disposed) return;
const anchor = pageAtMarker();
const before = isPdfPageVisible(anchor) ? anchor.getBoundingClientRect().top : undefined;
for (let i = Math.max(0, index - 2); i <= Math.min(chunks.length - 1, index + 2); i++)
mount(chunks[i]);
for (let i = 0; i < chunks.length; i++)
if (Math.abs(i - index) > 2) unmount(chunks[i]);
if (anchor?.isConnected && before !== undefined)
viewport.scrollTop += anchor.getBoundingClientRect().top - before;
}
function sync() {
frame = 0;
if (disposed) return;
const marker = viewport.scrollTop + viewport.clientHeight / 2;
const index = chunks.findIndex((chunk) => chunk.element.offsetTop + chunk.element.offsetHeight > marker);
show(index < 0 ? chunks.length - 1 : index);
}
mount(chunks[0]);
const resizeObserver = new ResizeObserver(() => resize());
resizeObserver.observe(viewport);
function resize() {
if (disposed) return;
const anchor = pageAtMarker();
const before = isPdfPageVisible(anchor) ? anchor.getBoundingClientRect().top : undefined;
for (const chunk of chunks) if (!chunk.mounted) chunk.element.style.height = `${chunkHeight(chunk)}px`;
if (anchor?.isConnected && before !== undefined)
viewport.scrollTop += anchor.getBoundingClientRect().top - before;
schedule();
}
function schedule() {
if (!disposed && !frame) frame = requestAnimationFrame(sync);
}
const window = {
ensure(page) {
const index = Math.floor((page - 1) / chunkSize);
show(index);
return chunks[index].element.querySelector(`.reader-page[data-page="${page}"]`);
},
remember(shell) {
if (!shell.isConnected) return;
const bounds = shell.getBoundingClientRect();
if (bounds.width && bounds.height) ratios.set(Number(shell.dataset.page), bounds.height / bounds.width);
},
schedule,
resize,
dispose() {
disposed = true;
if (frame) cancelAnimationFrame(frame);
resizeObserver.disconnect();
chunks.length = 0;
ratios.clear();
pdfShellWindow = null;
}
};
trackReaderResource(() => window.dispose());
return window;
}
async function renderPdfPages(prepared) {
const response = await prepared;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const manifest = VoiceOfMLReaderSecurity.validatePdfPageManifest(
JSON.parse(
await VoiceOfMLReaderSecurity.readText(response, VoiceOfMLReaderSecurity.LIMITS.manifestBytes)
)
);
assertReaderActive();
const source = pdfManifestSource();
const totalPages = manifest.page_count;
if (!source) throw new Error("PDF_MANIFEST_INVALID");
pdfPageManifest = { ...source, pageCount: totalPages };
updateDocumentState({ pageCount: totalPages });
pageInput.max = String(totalPages);
document.querySelector("#page-total").textContent = `/ ${totalPages}`;
status.textContent = `${totalPages} 页`;
await getInitialReaderRestoration();
const initialPage = initialReaderPage(totalPages);
if (ocrManifestUrl && !navigator.connection?.saveData) {
loadPdfOcrPage(initialPage).catch(() => {});
}
await preloadPdfFirstPage(totalPages);
let initialRenderGate = true;
if (Array.isArray(manifest.toc) && manifest.toc.length)
setToc(
manifest.toc.map((item) => ({
label: `${item.title} · 第 ${item.page} 页`,
depth: item.depth,
activate: (generation) => goToPage(item.page, generation)
}))
);
const observer = new IntersectionObserver(
(items) => {
if (readerAbortController.signal.aborted) return;
for (const entry of items) if (entry.isIntersecting) {
entry.target.dataset.renderVisible = isPdfPageVisible(entry.target) ? "1" : "0";
renderPdfInBackground(entry.target);
}
},
{ root: viewport, rootMargin: `${PDF_PREFETCH_MARGIN}px 0px` }
);
const visibleObserver = new IntersectionObserver(
(items) => {
if (readerAbortController.signal.aborted) return;
for (const entry of items) {
entry.target.dataset.renderVisible = entry.isIntersecting ? "1" : "0";
if (entry.isIntersecting) {
promotePdfRenderWaiter(entry.target);
renderPdfInBackground(entry.target, false, pdfPagePriority(entry.target));
if (entry.target.dataset.renderState === "rendered") renderPdfOcrText(entry.target);
}
}
},
{ root: viewport, threshold: 0 }
);
const observeShell = (shell) => {
if (initialRenderGate) {
shell._pdfObserversPending = true;
return;
}
observer.observe(shell);
visibleObserver.observe(shell);
};
const releaseInitialRenderGate = () => {
initialRenderGate = false;
for (const shell of content.querySelectorAll(".reader-page")) {
if (!shell._pdfObserversPending) continue;
delete shell._pdfObserversPending;
observeShell(shell);
}
};
trackReaderResource(() => {
observer.disconnect();
visibleObserver.disconnect();
});
const createShell = (page) => {
const shell = document.createElement("section");
shell.className = "reader-page";
shell.dataset.page = String(page);
shell.style.aspectRatio = "1 / 1.414";
pdfManifestShells[page] = shell;
shell.tabIndex = 0;
shell.setAttribute("role", "region");
shell.setAttribute("aria-label", `第 ${page} 页`);
const pageState = document.createElement("div");
pageState.className = "reader-page-state";
pageState.hidden = true;
pageState.textContent = `正在加载第 ${page} 页...`;
const textLayer = document.createElement("div");
textLayer.className = "reader-pdf-text";
textLayer.setAttribute("role", "document");
textLayer.setAttribute("aria-label", `第 ${page} 页正文`);
shell.append(pageState, textLayer);
if (
page === pdfFirstPagePreloadPage &&
pdfFirstPagePreload &&
(!pdfFirstPagePreload.complete || pdfFirstPagePreload.naturalWidth)
)
shell.appendChild(pdfFirstPagePreload);
shell.addEventListener("focus", () => renderPdfInBackground(shell));
observeShell(shell);
return shell;
};
const firstShell = createShell(1);
content.appendChild(firstShell);
markReaderContentReady();
pdfShellWindow = createPdfShellWindow(totalPages, firstShell, createShell, (shell) => {
observer.unobserve(shell);
visibleObserver.unobserve(shell);
}, 1.414);
pdfShellsReady = pdfShellWindow ? Promise.resolve() : appendShellsBatched(createShell, totalPages, 250);
pdfShellsReady.catch(() => {});
try {
const targetShell = pdfShellWindow
? pdfShellWindow.ensure(initialPage)
: (await pdfShellsReady, content.querySelector(`.reader-page[data-page="${initialPage}"]`));
if (!targetShell) throw new Error("PDF initial page shell missing");
await renderPdfManifestShell(targetShell, false, true);
markReaderContentReady();
viewport.scrollTop = targetShell.offsetTop;
} finally {
releaseInitialRenderGate();
}
}
async function renderPdf(prepared) {
const pdf = await prepared;
assertReaderActive();
pdfDocument = pdf;
updateDocumentState({ pageCount: pdf.numPages });
pageInput.max = String(documentState.pageCount);
document.querySelector("#page-total").textContent = `/ ${documentState.pageCount}`;
status.textContent = `${pdf.numPages} 页`;
await getInitialReaderRestoration();
const initialPage = initialReaderPage(pdf.numPages);
let initialRenderGate = true;
const firstPage = await pdf.getPage(initialPage);
assertReaderActive();
const firstViewport = firstPage.getViewport({ scale: 1 });
const observer = new IntersectionObserver(
(entries) =>
entries.forEach((entry) => {
if (readerAbortController.signal.aborted) return;
entry.target.dataset.renderVisible = entry.isIntersecting && isPdfPageVisible(entry.target) ? "1" : "0";
if (entry.isIntersecting) renderPdfInBackground(entry.target);
}),
{ root: document.querySelector("#viewport"), rootMargin: `${PDF_PREFETCH_MARGIN}px 0px` }
);
const visibleObserver = new IntersectionObserver(
(entries) =>
entries.forEach((entry) => {
if (readerAbortController.signal.aborted) return;
entry.target.dataset.renderVisible = entry.isIntersecting ? "1" : "0";
if (entry.isIntersecting) {
promotePdfRenderWaiter(entry.target);
if (pdfUserHasScrolled || entry.target.dataset.renderState !== "rendering")
renderPdfInBackground(entry.target, false, pdfPagePriority(entry.target));
}
}),
{ root: document.querySelector("#viewport"), threshold: 0 }
);
const pageObserver = new IntersectionObserver(() => scheduleMarkerSync(), {
root: document.querySelector("#viewport"),
threshold: [0, 0.5, 1]
});
const observeShell = (shell) => {
if (initialRenderGate) {
shell._pdfObserversPending = true;
return;
}
observer.observe(shell);
visibleObserver.observe(shell);
pageObserver.observe(shell);
};
const releaseInitialRenderGate = () => {
initialRenderGate = false;
for (const shell of content.querySelectorAll(".reader-page")) {
if (!shell._pdfObserversPending) continue;
delete shell._pdfObserversPending;
observeShell(shell);
}
};
trackReaderResource(() => {
observer.disconnect();
visibleObserver.disconnect();
pageObserver.disconnect();
});
const createShell = (page) => {
const shell = document.createElement("section");
shell.className = "reader-page";
shell.dataset.page = String(page);
shell.style.aspectRatio = `${firstViewport.width} / ${firstViewport.height}`;
shell.tabIndex = 0;
shell.setAttribute("role", "region");
shell.setAttribute("aria-label", `第 ${page} 页`);
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
const textLayer = document.createElement("div");
textLayer.className = "reader-pdf-text";
textLayer.setAttribute("role", "document");
textLayer.setAttribute("aria-label", `第 ${page} 页正文`);
shell.append(canvas, textLayer);
shell.addEventListener("focus", () => renderPdfInBackground(shell));
observeShell(shell);
return shell;
};
const firstShell = createShell(1);
content.appendChild(firstShell);
markReaderContentReady();
pdfShellWindow = createPdfShellWindow(pdf.numPages, firstShell, createShell, (shell) => {
observer.unobserve(shell);
visibleObserver.unobserve(shell);
pageObserver.unobserve(shell);
}, firstViewport.height / firstViewport.width);
pdfShellsReady = pdfShellWindow ? Promise.resolve() : appendShellsBatched(createShell, pdf.numPages, 24);
try {
const targetShell = pdfShellWindow
? pdfShellWindow.ensure(initialPage)
: (await pdfShellsReady, content.querySelector(`.reader-page[data-page="${initialPage}"]`));
if (!targetShell) throw new Error("PDF initial page shell missing");
await renderPdfShell(targetShell, false, true);
markReaderContentReady();
viewport.scrollTop = targetShell.offsetTop;
} finally {
releaseInitialRenderGate();
}
const outlineReady = (async () => {
if (typeof pdf.getOutline !== "function") return;
try {
const outline = await awaitReader(pdf.getOutline()),
entries = [];
const cleanLabel = (value) =>
String(value || "未命名章节")
.replace(/[\u2610-\u2612\u25a1]\s*[xX×]?\s*$/u, "")
.trim() || "未命名章节";
const append = async (items, depth = 0) => {
for (const item of items || []) {
assertReaderActive();
let destination = item.dest,
page = 0;
if (typeof destination === "string") destination = await pdf.getDestination(destination);
if (destination && destination[0]) {
try {
page = (await pdf.getPageIndex(destination[0])) + 1;
} catch (_) {}
}
entries.push({
label: page ? `${cleanLabel(item.title)} · 第 ${page} 页` : cleanLabel(item.title),
depth,
activate: (generation) => (page ? goToPage(page, generation) : false)
});
await append(item.items, depth + 1);
}
};
await append(outline);
assertReaderActive();
if (entries.length) setToc(entries);
} catch (error) {
if (!readerAbortController.signal.aborted)
console.warn("PDF outline could not be loaded", error);
}
})();
outlineReady.catch(() => {});
await pdfShellsReady;
assertReaderActive();
}
function isPdfPageVisible(shell) {
const pageRect = shell?.getBoundingClientRect?.();
const viewportRect = viewport?.getBoundingClientRect?.();
return !!pageRect && !!viewportRect && pageRect.bottom > viewportRect.top && pageRect.top < viewportRect.bottom;
}
function pdfPagePriority(shell) {
const pageRect = shell?.getBoundingClientRect?.();
const viewportRect = viewport?.getBoundingClientRect?.();
if (!pageRect || !viewportRect) return 0;
if (pageRect.top >= viewportRect.top && pageRect.bottom <= viewportRect.bottom) return 3;
if (pageRect.bottom > viewportRect.top && pageRect.top < viewportRect.bottom) return 2;
return pageRect.bottom > viewportRect.top - PDF_PREFETCH_MARGIN &&
pageRect.top < viewportRect.bottom + PDF_PREFETCH_MARGIN ? 1 : 0;
}
function normalizePdfPriority(priority) {
return priority === true ? 3 : Math.max(0, Math.min(3, Number(priority) || 0));
}
function renderPdfInBackground(shell, force = false, priority = pdfPagePriority(shell)) {
priority = normalizePdfPriority(priority);
if (readerAbortController.signal.aborted) return;
if (priority) {
shell.dataset.renderPriority = "1";
promotePdfRenderWaiter(shell);
if (capability.mode === "pdf-pages" && pdfPageManifest)
prioritizePdfManifestPrefetch(pdfPageManifest.pageUrl(Number(shell.dataset.page)));
}
if (shell._backgroundRender) {
if (priority && capability.mode === "pdf-pages") {
const image = shell.querySelector("img");
if (image) image.fetchPriority = priority >= 3 ? "high" : "auto";
}
if (force) shell.dataset.pendingRerender = "1";
return shell._backgroundRender;
}
const task =
capability.mode === "pdf-pages"
? renderPdfManifestShell(shell, force, priority)
: renderPdfShell(shell, force, priority);
shell._backgroundRender = task;
task
.catch((error) => {
if (readerAbortController.signal.aborted || error?.name === "AbortError") return;
console.warn(`PDF page ${shell.dataset.page} render failed`, error);
const retries = Number(shell.dataset.renderRetries || 0);
if (retries < 3) {
shell.dataset.renderRetries = String(retries + 1);
waitForReader(400 * (retries + 1))
.then(() => {
if (shell.isConnected) renderPdfInBackground(shell);
})
.catch(() => {});
}
})
.finally(() => {
if (shell._backgroundRender === task) delete shell._backgroundRender;
});
return task;
}
function renderPdfManifestShell(shell, force = false, priority = false) {
if (!shell) return Promise.reject(new Error("PDF page shell missing"));
if (shell._renderPromise) return shell._renderPromise;
if (!force && shell.dataset.renderState === "rendered") return Promise.resolve();
shell.dataset.renderState = "rendering";
const renderController = new AbortController();
shell._renderCancel = () => renderController.abort();
shell._renderStarted = false;
priority = normalizePdfPriority(priority || pdfPagePriority(shell));
shell._renderActivePriority = String(priority);
const task = (async () => {
let acquired = false;
try {
await acquirePdfRenderSlot(priority, shell, renderController.signal);
acquired = true;
shell._renderStarted = true;
assertReaderActive();
const entry = pdfPageEntry(Number(shell.dataset.page));
if (!entry) throw new Error("PDF page entry missing");
const target = pdfPageManifest.pageUrl(entry.page);
let loadText = null;
let image = shell.querySelector("img");
const pageState = shell.querySelector(".reader-page-state");
if (!image) {
image = takePdfManifestPrefetch(target) || new Image();
image.alt = `第 ${entry.page} 页`;
image.decoding = "async";
shell.appendChild(image);
}
image.fetchPriority = priority >= 3 ? "high" : priority >= 1 ? "auto" : "low";
await new Promise((resolve, reject) => {
let settled = false,
timeout = 0,
untrack = () => {};
const abort = () => finish(readerAbortError());
const finish = (error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
untrack();
renderController.signal.removeEventListener("abort", abort);
image.onload = image.onerror = null;
if (error) {
image.removeAttribute("src");
reject(error);
} else resolve();
};
untrack = trackReaderResource(() => finish(readerAbortError()));
renderController.signal.addEventListener("abort", abort, { once: true });
if (settled) return;
timeout = setTimeout(
() => finish(new Error("PDF page image timeout")),
READER_PROXY_TIMEOUT_MS
);
image.onload = () => finish();
image.onerror = () => finish(new Error(`PDF page ${entry.page} image failed`));
if (image.src !== target) image.src = target;
if (priority >= 2 && ocrManifestUrl && !navigator.connection?.saveData)
loadText = renderPdfOcrText(shell);
loadText?.catch(() => {});
if (image.complete && image.naturalWidth) finish();
});
assertReaderActive();
shell.style.aspectRatio = `${image.naturalWidth || 1} / ${image.naturalHeight || 1}`;
if (priority >= 2 && ocrManifestUrl && !navigator.connection?.saveData)
loadText ||= renderPdfOcrText(shell);
loadText?.catch(() => {});
image.classList.add("ready");
if (pageState) pageState.hidden = true;
shell.dataset.renderState = "rendered";
shell.dataset.renderUsedAt = String(Date.now());
pdfShellWindow?.remember(shell);
if (isPdfPageVisible(shell) || !pdfManifestPrefetchOrigin) schedulePdfManifestPrefetch(entry.page);
else if (!pdfManifestPrefetchActive.size && !pdfManifestPrefetchTimer) queuePdfManifestPrefetch();
trimPdfManifestImages(shell);
scheduleMarkerSync();
} catch (error) {
shell.querySelector("img")?.remove();
const pageState = shell.querySelector(".reader-page-state");
if (pageState) {
pageState.hidden = false;
pageState.textContent = "页面加载失败,正在重试…";
}
shell.dataset.renderState = "idle";
throw error;
} finally {
if (acquired) releasePdfRenderSlot();
}
})().finally(() => {
if (shell._renderPromise === task) delete shell._renderPromise;
if (shell._renderCancel) delete shell._renderCancel;
delete shell._renderStarted;
delete shell._renderActivePriority;
});
shell._renderPromise = task;
return task;
}
function cancelSpeculativePdfRenders(protectedShell) {
for (const shell of content.querySelectorAll('.reader-page[data-render-state="rendering"]')) {
if (
shell === protectedShell ||
isPdfPageVisible(shell)
)
continue;
shell._renderCancel?.();
}
}
function renderPdfShell(shell, force = false, priority = false) {
if (shell._renderPromise) {
if (force) shell.dataset.pendingRerender = "1";
return shell._renderPromise;
}
if (!force && shell.dataset.renderState === "rendered") return Promise.resolve();
shell.dataset.renderState = "rendering";
const renderController = new AbortController();
priority = normalizePdfPriority(priority || pdfPagePriority(shell));
let activeRendering = null;
shell._renderCancel = () => {
renderController.abort();
activeRendering?.cancel?.();
};
const task = (async () => {
let acquired = false;
try {
await acquirePdfRenderSlot(priority, shell, renderController.signal);
acquired = true;
assertReaderActive();
if (renderController.signal.aborted) throw readerAbortError();
const pdf = pdfDocument;
if (!pdf) throw readerAbortError();
do {
delete shell.dataset.pendingRerender;
const generation = readerRuntime.currentGeneration("pdf"),
page = await awaitReader(pdf.getPage(Number(shell.dataset.page)));
assertReaderActive();
if (renderController.signal.aborted) throw readerAbortError();
const base = page.getViewport({ scale: 1 }),
scale = Math.min(3, Math.max(0.5, shell.clientWidth / base.width)),
viewport = page.getViewport({ scale }),
outputScale = Math.min(2, Math.max(1, Number(window.devicePixelRatio) || 1));
const canvas = shell.querySelector("canvas");
canvas.width = Math.ceil(viewport.width * outputScale);
canvas.height = Math.ceil(viewport.height * outputScale);
canvas.style.width = "100%";
canvas.style.height = "auto";
shell.style.aspectRatio = `${viewport.width} / ${viewport.height}`;
const rendering = page.render({
canvasContext: canvas.getContext("2d"),
viewport,
transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0]
});
activeRendering = rendering;
const untrack = trackReaderResource(() => rendering.cancel?.());
const textTask = renderPdfText(page, shell, renderController.signal);
textTask.catch(() => {});
try {
// The canvas is the first visible result. Text extraction can need a
// separate range and must not delay the visual first page.
await awaitReader(rendering.promise);
} finally {
untrack();
activeRendering = null;
}
assertReaderActive();
if (renderController.signal.aborted) throw readerAbortError();
if (!isReaderGenerationCurrent("pdf", generation)) shell.dataset.pendingRerender = "1";
} while (shell.dataset.pendingRerender);
shell.querySelector("canvas").classList.add("ready");
shell.dataset.renderState = "rendered";
shell.dataset.renderUsedAt = String(Date.now());
pdfShellWindow?.remember(shell);
trimPdfCanvases(shell);
} catch (error) {
shell.dataset.renderState = "idle";
throw error;
} finally {
if (acquired) releasePdfRenderSlot();
}
})().finally(() => {
if (shell._renderPromise === task) delete shell._renderPromise;
if (shell._renderCancel) delete shell._renderCancel;
});
shell._renderPromise = task;
return task;
}
async function renderPdfText(page, shell, signal = null) {
if (shell.dataset.textReady === "1") return;
const layer = shell.querySelector(".reader-pdf-text");
if (!layer || typeof page.getTextContent !== "function") return;
const epoch = shell._textEpoch || 0;
try {
const pageNumber = Number(shell.dataset.page);
const cached = cachedPdfTextContent(pageNumber);
const text = cached || await awaitReader(page.getTextContent());
assertReaderActive();
if (signal?.aborted) return;
if (!cached) cachePdfTextContent(pageNumber, text);
if (epoch !== (shell._textEpoch || 0) || !shell.isConnected) return;
const pdfViewport = page.getViewport({ scale: 1 });
shell._bookmarkTextItems = text.items
.filter((item) => item.str && item.str.trim())
.map((item) => {
const point =
item.transform && pdfViewport.convertToViewportPoint
? pdfViewport.convertToViewportPoint(item.transform[4], item.transform[5])
: null;
return {
text: item.str,
y: point ? Math.max(0, Math.min(1, point[1] / Math.max(1, pdfViewport.height))) : 0
};
});
populatePdfTextLayer(layer, text.items, pdfViewport, text.styles);
shell.dataset.textReady = "1";
highlightPdfText(shell);
} catch (error) {
if (!readerAbortController.signal.aborted && !signal?.aborted)
console.warn(`PDF page ${shell.dataset.page} text extraction failed`, error);
}
}
function highlightPdfText(shell, query = searchState.query) {
if (!shell || readerAbortController.signal.aborted || !query) return;
const layer = shell.querySelector(".reader-pdf-text");
if (!layer || layer.querySelector("mark")) return;
const walker = document.createTreeWalker(layer, NodeFilter.SHOW_TEXT), runs = [];
let raw = "";
while (walker.nextNode()) {
const node = walker.currentNode, start = raw.length;
raw += node.data;
runs.push({ node, start, end: raw.length });
}
const mapping = pdfSearchText(raw);
const hits = [];
for (const hit of mapping.text.matchAll(new RegExp(fullSearchEscape(pdfSearchText(query).text), "giu"))) {
hits.push(hit);
if (hits.length === 100) break;
}
// Bound highlights in the displayed page, without limiting searchable results.
for (const hit of hits.reverse()) {
const start = mapping.offsets[hit.index], end = mapping.offsets[hit.index + hit[0].length - 1] + 1;
highlightTextParts(runs.filter((run) => run.end > start && run.start < end).map((run) => ({
node: run.node, start: Math.max(start, run.start) - run.start, end: Math.min(end, run.end) - run.start
})));
}
}
function promotePdfRenderWaiter(shell) {
for (const waiter of pdfRenderWaiters) {
if (waiter.shell === shell) waiter.priority = Math.max(waiter.priority, pdfPagePriority(shell));
}
}
function acquirePdfRenderSlot(priority = false, shell = null, signal = null) {
if (readerAbortController.signal.aborted) return Promise.reject(readerAbortError());
if (signal?.aborted) return Promise.reject(readerAbortError());
const limit = matchMedia("(max-width: 700px)").matches ? 1 : 2;
if (pdfActiveRenders < limit) {
pdfActiveRenders++;
return Promise.resolve();
}
return new Promise((resolve, reject) => {
let settled = false;
const remove = () => {
const index = pdfRenderWaiters.indexOf(waiter);
if (index >= 0) pdfRenderWaiters.splice(index, 1);
signal?.removeEventListener("abort", abort);
};
const abort = () => {
if (settled) return;
settled = true;
remove();
reject(readerAbortError());
};
priority = normalizePdfPriority(priority);
const waiter = {
shell,
priority,
resolve: () => {
if (settled) return;
settled = true;
signal?.removeEventListener("abort", abort);
pdfActiveRenders++;
resolve();
},
reject: (error) => {
if (settled) return;
settled = true;
signal?.removeEventListener("abort", abort);
reject(error);
}
};
signal?.addEventListener("abort", abort, { once: true });
const index = pdfRenderWaiters.findIndex((item) => item.priority < priority);
if (index < 0) pdfRenderWaiters.push(waiter);
else pdfRenderWaiters.splice(index, 0, waiter);
});
}
function releasePdfRenderSlot() {
pdfActiveRenders = Math.max(0, pdfActiveRenders - 1);
if (readerAbortController.signal.aborted) return;
let index = -1, best = 0;
for (let i = 0; i < pdfRenderWaiters.length; i++) {
const waiter = pdfRenderWaiters[i], value = Math.max(
waiter.priority, waiter.shell ? pdfPagePriority(waiter.shell) : 0
);
if (value > best) { best = value; index = i; }
}
if (index < 0) index = 0;
const waiter = pdfRenderWaiters.splice(index, 1)[0];
if (waiter) waiter.resolve();
}
function acquirePdfTextSlot(priority = false) {
if (readerAbortController.signal.aborted) return Promise.reject(readerAbortError());
const limit = matchMedia("(max-width: 700px)").matches ? 1 : 2;
if (pdfActiveTextLoads < limit) {
pdfActiveTextLoads++;
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const waiter = {
resolve: () => {
pdfActiveTextLoads++;
resolve();
},
reject
};
if (priority) pdfTextWaiters.unshift(waiter);
else pdfTextWaiters.push(waiter);
});
}
function releasePdfTextSlot() {
pdfActiveTextLoads = Math.max(0, pdfActiveTextLoads - 1);
if (readerAbortController.signal.aborted) return;
const waiter = pdfTextWaiters.shift();
if (waiter) waiter.resolve();
}
function trimPdfCanvases(protectedShell) {
const limit = matchMedia("(max-width: 700px)").matches ? 7 : 11;
const rendered = [...content.querySelectorAll('.reader-page[data-render-state="rendered"]')];
if (rendered.length <= limit) return;
rendered.sort((a, b) => {
const aVisible =
a === protectedShell ||
a.dataset.renderVisible === "1" ||
Number(a.dataset.page) === documentState.page;
const bVisible =
b === protectedShell ||
b.dataset.renderVisible === "1" ||
Number(b.dataset.page) === documentState.page;
if (aVisible !== bVisible) return aVisible ? 1 : -1;
const distance =
Math.abs(Number(b.dataset.page) - documentState.page) -
Math.abs(Number(a.dataset.page) - documentState.page);
return distance || Number(a.dataset.renderUsedAt || 0) - Number(b.dataset.renderUsedAt || 0);
});
while (rendered.length > limit) {
const shell = rendered.shift();
if (
shell === protectedShell ||
shell.dataset.renderVisible === "1" ||
Number(shell.dataset.page) === documentState.page ||
pdfSelectionIntersects(shell)
)
continue;
const canvas = shell.querySelector("canvas");
shell._textEpoch = (shell._textEpoch || 0) + 1;
canvas.width = 0;
canvas.height = 0;
canvas.classList.remove("ready");
const textLayer = shell.querySelector(".reader-pdf-text");
if (textLayer) textLayer.replaceChildren();
shell._bookmarkTextItems = [];
shell.dataset.textReady = "0";
shell.dataset.renderState = "idle";
fullSearchActiveMarks = fullSearchActiveMarks.filter((mark) => mark.isConnected);
}
}
function rerenderVisiblePdfPages() {
nextReaderGeneration("pdf");
for (const shell of content.querySelectorAll(".reader-page")) {
const rect = shell.getBoundingClientRect();
if (rect.bottom >= -1200 && rect.top <= innerHeight + 1200) {
if (shell._renderPromise) shell.dataset.pendingRerender = "1";
else renderPdfInBackground(shell, true);
}
}
}
async function renderText(markdown, prepared) {
let response = await prepared.response;
if (!response.ok) response = await fetchReaderResponse();
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!markdown) await renderPlainText(response);
else {
const bytes = await VoiceOfMLReaderSecurity.readBytes(
response,
VoiceOfMLReaderSecurity.LIMITS.documentBytes
);
const text = new TextDecoder(detectTextEncoding(bytes, documentState.title)).decode(bytes);
await prepared.engines;
const article = document.createElement("article");
article.className = "reader-markdown";
article.innerHTML = DOMPurify.sanitize(marked.parse(text), { USE_PROFILES: { html: true } });
content.appendChild(article);
setToc(headingTocEntries(article));
}
markReaderContentReady();
status.textContent = "已加载";
}
// HTML documents retain an isolated, script-free scroll window.
const HTML_FRAME_STYLE =
'<meta name="color-scheme" content="only light"><style>:root{color-scheme:only light!important;background:#fff!important}html,body{min-height:100%;background:#fff!important;color:#111!important}</style>';
const DISCOURSE_POST_SELECTOR = ".topic-post .cooked";
const DISCOURSE_TITLE_SELECTOR = "#topic-title .fancy-title, .topic-link, title";
function sanitizeOfflineHtml(text) {
const clean = DOMPurify.sanitize(text, {
USE_PROFILES: { html: true },
ADD_TAGS: ["style"],
FORBID_TAGS: ["base", "embed", "form", "iframe", "object", "script"],
FORBID_ATTR: ["action", "formaction", "srcdoc"],
ALLOWED_URI_REGEXP: /^data:image\/(?:gif|png|jpeg|webp|svg\+xml);/i
});
const template = document.createElement("template");
template.innerHTML = clean;
const stripUrls = (value) => value.replace(/@import[^;]+;|url\s*\([^)]*\)/gi, "");
for (const style of template.content.querySelectorAll("style"))
style.textContent = stripUrls(style.textContent);
for (const element of template.content.querySelectorAll("[style]"))
element.setAttribute("style", stripUrls(element.getAttribute("style")));
return template.innerHTML;
}
function buildDiscourseDocument(text) {
try {
const source = new DOMParser().parseFromString(text, "text/html");
const posts = [...source.querySelectorAll(DISCOURSE_POST_SELECTOR)];
if (!source.querySelector(".post-stream") || !posts.length) return null;
const article = source.createElement("article");
article.className = "reader-forum-document";
const titleNode = source.querySelector(DISCOURSE_TITLE_SELECTOR);
const title = titleNode?.textContent?.trim();
if (title) {
const heading = source.createElement("h1");
heading.textContent = title;
article.appendChild(heading);
}
posts.forEach((post, index) => {
const section = source.createElement("section");
section.className = "reader-forum-post";
section.dataset.post = String(index + 1);
section.innerHTML = post.innerHTML;
article.appendChild(section);
});
return article.outerHTML;
} catch (_) {
return null;
}
}
function prepareHtmlDocument(text) {
const discourse = buildDiscourseDocument(text);
const html = sanitizeOfflineHtml(discourse || text);
return {
html,
discourse: !!discourse,
spreadsheet: !discourse && html.includes("reader-spreadsheet-sheet")
};
}
function buildHtmlFrameSource(documentData) {
return documentData.html + HTML_FRAME_STYLE +
(documentData.discourse ? DISCOURSE_HTML_STYLE : "") +
(documentData.spreadsheet ? SPREADSHEET_HTML_STYLE : "");
}
const DISCOURSE_HTML_STYLE = `<style>
html,body{min-height:100%;margin:0;background:#fff;color:#202124}
body{box-sizing:border-box;padding:clamp(18px,4vw,48px);font:16px/1.8 system-ui,-apple-system,"Segoe UI","Noto Sans SC",sans-serif;overflow-wrap:anywhere}
.reader-forum-document{max-width:900px;margin:0 auto}
.reader-forum-document>h1{margin:0 0 1.8em;padding:0 0 .7em;border-bottom:1px solid #d9dde3;font-size:clamp(1.5rem,3vw,2.25rem);line-height:1.35}
.reader-forum-post{margin:0 0 2.5em;padding:0 0 2em;border-bottom:1px solid #e1e5ea}
.reader-forum-post:last-child{margin-bottom:0;border-bottom:0}
.reader-forum-post p{margin:.8em 0}
.reader-forum-post a{color:#185abc}
.reader-forum-post img,.reader-forum-post svg,.reader-forum-post video{max-width:100%;height:auto}
.reader-forum-post table{display:block;max-width:100%;overflow-x:auto;border-collapse:collapse}
.reader-forum-post blockquote{margin:1em 0;padding:.5em 1em;border-left:3px solid #c5ccd6;background:#f6f8fa}
.reader-forum-post pre{max-width:100%;overflow:auto;white-space:pre-wrap}
@media(max-width:600px){body{padding:16px 14px}.reader-forum-document>h1{font-size:1.5rem}}
</style>`;
const SPREADSHEET_HTML_STYLE = `<style>
html,body{min-height:100%;margin:0;background:#fff;color:#16191c}
html{width:max-content;min-width:100%;overflow:auto}
body{box-sizing:border-box;width:max-content;min-width:100%;padding:12px;font:14px/1.35 Arial,"Noto Sans",sans-serif}
.reader-spreadsheet-sheet{width:max-content;min-width:100%;margin:0 0 24px}
.reader-spreadsheet-sheet h2{position:sticky;left:0;width:max-content;margin:0 0 8px;font-size:16px}
.reader-spreadsheet-sheet table{width:max-content;min-width:max-content;border-collapse:collapse;border-spacing:0}
.reader-spreadsheet-sheet th,.reader-spreadsheet-sheet td{white-space:nowrap;vertical-align:top;padding:4px 8px;border:1px solid #c8ccd1}
.reader-spreadsheet-sheet img,.reader-spreadsheet-sheet svg{max-width:none;height:auto}
</style>`;
async function waitForSpreadsheetImages(frame) {
const images = [...(frame.contentDocument?.images || [])];
if (!images.length) return;
await Promise.race([
Promise.all(images.map(async (image) => {
image.loading = "eager";
image.decoding = "async";
if (!image.complete) {
await new Promise((resolve) => {
const finish = () => {
image.removeEventListener("load", finish);
image.removeEventListener("error", finish);
resolve();
};
image.addEventListener("load", finish, { once: true });
image.addEventListener("error", finish, { once: true });
});
}
try { await image.decode?.(); } catch (_) {}
})),
new Promise((resolve) => setTimeout(resolve, 30000))
]);
}
async function renderHtml(prepared) {
const response = await prepared.response;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const bytes = await VoiceOfMLReaderSecurity.readBytes(response);
const text = new TextDecoder(detectHtmlEncoding(bytes, documentState.title)).decode(bytes);
await prepared.engine;
assertReaderActive();
const documentData = prepareHtmlDocument(text);
const frame = document.createElement("iframe");
htmlFrame = frame;
frame.className = "html-frame";
content.classList.toggle("reader-content--spreadsheet", documentData.spreadsheet);
if (documentData.spreadsheet) frame.classList.add("spreadsheet-html-frame");
frame.title = `${documentState.title} 正文`;
frame.setAttribute("sandbox", "allow-same-origin");
frame.setAttribute("referrerpolicy", "no-referrer");
frame.style.colorScheme = "only light";
const frameLoaded = new Promise((resolve, reject) => {
let untrack = () => {};
const loaded = async () => {
untrack();
try {
assertReaderActive();
repairHtmlContrast(frame);
frame.contentDocument.documentElement.style.zoom = String(documentState.zoom);
frame.contentWindow.addEventListener("scroll", handleReaderPositionChange, {
passive: true
});
frame.contentDocument.addEventListener("keydown", handleReaderKeydown);
for (const type of ["wheel", "touchstart", "pointerdown"])
frame.contentDocument.addEventListener(type, beginReaderNavigation, { passive: true });
setToc(headingTocEntries(frame.contentDocument));
if (documentData.spreadsheet) await waitForSpreadsheetImages(frame);
resolve();
} catch (error) {
reject(error);
}
};
untrack = trackReaderResource(() => {
frame.removeEventListener("load", loaded);
reject(readerAbortError());
});
frame.addEventListener("load", loaded, { once: true });
});
frame.srcdoc = buildHtmlFrameSource(documentData);
content.appendChild(frame);
await frameLoaded;
markReaderContentReady();
status.textContent = documentData.spreadsheet ? "表格" : "HTML";
}
function detectHtmlEncoding(bytes, hint = "") {
const probe = String.fromCharCode(...bytes.subarray(0, 8192));
const match = probe.match(/charset\s*=\s*["']?\s*([a-z0-9._:-]+)/i);
if (match) {
const label =
{ gb2312: "gb18030", "gb-2312": "gb18030", gbk: "gb18030", "x-gbk": "gb18030" }[
match[1].toLowerCase()
] || match[1];
try {
new TextDecoder(label);
return label;
} catch (_) {}
}
return detectTextEncoding(bytes, hint);
}
function repairHtmlContrast(frame) {
const doc = frame.contentDocument;
if (!doc || !doc.body) return;
const parseColor = (value) => {
const parts = String(value).match(/[\d.]+/g);
return parts && parts.length >= 3
? [
Number(parts[0]),
Number(parts[1]),
Number(parts[2]),
parts[3] === undefined ? 1 : Number(parts[3])
]
: null;
};
const luminance = (color) => {
const channels = color.slice(0, 3).map((value) => {
const normalized = value / 255;
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
});
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
};
const background = (element) => {
for (let current = element; current; current = current.parentElement) {
const color = parseColor(frame.contentWindow.getComputedStyle(current).backgroundColor);
if (color && color[3] > 0.1) return color;
}
return [255, 255, 255, 1];
};
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
const elements = new Set();
while (walker.nextNode() && elements.size < 10000)
if (walker.currentNode.data.trim()) elements.add(walker.currentNode.parentElement);
for (const element of elements) {
const foreground = parseColor(frame.contentWindow.getComputedStyle(element).color);
const backdrop = background(element);
if (!foreground) continue;
const light = luminance(foreground),
dark = luminance(backdrop);
const contrast = (Math.max(light, dark) + 0.05) / (Math.min(light, dark) + 0.05);
if (contrast < 3)
element.style.setProperty("color", dark > 0.45 ? "#111" : "#f5f5f5", "important");
}
}
async function renderPlainText(response) {
assertReaderActive();
const pre = document.createElement("pre");
pre.className = "reader-text";
content.appendChild(pre);
const limit = VoiceOfMLReaderSecurity.LIMITS.documentBytes;
VoiceOfMLReaderSecurity.assertResponseSize(response, limit);
if (!response.body?.getReader) {
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > limit) {
const error = new Error("READER_RESOURCE_LIMIT");
error.code = "READER_RESOURCE_LIMIT";
throw error;
}
assertReaderActive();
pre.textContent = new TextDecoder(detectTextEncoding(bytes, documentState.title))
.decode(bytes)
.replace(/\ufffd/g, "");
return;
}
const reader = response.body.getReader(),
sampleChunks = [];
const untrack = trackReaderResource(() => {
reader.cancel(readerAbortError()).catch(() => {});
});
let length = 0,
sampleLength = 0,
displayedSampleSize = 0,
decoder = null,
asciiPreviewPossible = true,
asciiPrefixLength = 0;
const appendDecoded = (value) => {
const text = value.replace(/\ufffd/g, "");
if (!text) return;
// Search can split or normalize previous chunks while the response streams.
const tail = pre.lastChild;
if (tail?.nodeType === Node.TEXT_NODE) tail.appendData(text);
else pre.appendChild(document.createTextNode(text));
};
const startDecoder = () => {
const sample = new Uint8Array(sampleLength);
let offset = 0;
for (const chunk of sampleChunks) {
sample.set(chunk, offset);
offset += chunk.byteLength;
}
decoder = new TextDecoder(detectTextEncoding(sample, documentState.title));
appendDecoded(decoder.decode(sample.subarray(displayedSampleSize), { stream: true }));
sampleChunks.length = 0;
};
try {
while (true) {
const { value, done } = await awaitReader(reader.read());
assertReaderActive();
if (done) break;
if (!value?.byteLength) continue;
length += value.byteLength;
if (length > limit) {
const error = new Error("READER_RESOURCE_LIMIT");
error.code = "READER_RESOURCE_LIMIT";
throw error;
}
if (!decoder) {
sampleChunks.push(value);
sampleLength += value.byteLength;
if (asciiPreviewPossible) {
let asciiLength = 0;
while (asciiLength < value.length && value[asciiLength] > 0 && value[asciiLength] < 128)
asciiLength++;
asciiPrefixLength += asciiLength;
if (value[asciiLength] === 0) {
// A split UTF-16 prefix must not leave an ASCII preview behind.
pre.textContent = "";
displayedSampleSize = 0;
asciiPrefixLength = 0;
asciiPreviewPossible = false;
} else if (asciiLength < value.length) asciiPreviewPossible = false;
}
if (asciiPrefixLength >= 4 && asciiPrefixLength > displayedSampleSize) {
let offset = 0;
for (const chunk of sampleChunks) {
const start = Math.max(0, displayedSampleSize - offset),
end = Math.min(chunk.length, asciiPrefixLength - offset);
if (end > start)
appendDecoded(new TextDecoder("utf-8").decode(chunk.subarray(start, end)));
offset += chunk.length;
if (offset >= asciiPrefixLength) break;
}
displayedSampleSize = asciiPrefixLength;
}
if (sampleLength < 65540) continue;
startDecoder();
} else {
appendDecoded(decoder.decode(value, { stream: true }));
}
}
if (!decoder) startDecoder();
appendDecoded(decoder.decode());
} catch (reason) {
try {
await reader.cancel(reason);
} catch (_) {}
throw reason;
} finally {
untrack();
reader.releaseLock();
}
}
function detectTextEncoding(bytes, hint = "") {
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return "utf-8";
if (bytes[0] === 0xff && bytes[1] === 0xfe) return "utf-16le";
if (bytes[0] === 0xfe && bytes[1] === 0xff) return "utf-16be";
const evenNulls = bytes.filter((value, index) => !value && index % 2 === 0).length;
const oddNulls = bytes.filter((value, index) => !value && index % 2 === 1).length;
if (oddNulls > bytes.length / 8 && oddNulls > evenNulls * 4) return "utf-16le";
if (evenNulls > bytes.length / 8 && evenNulls > oddNulls * 4) return "utf-16be";
try {
new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true });
return "utf-8";
} catch (_) {}
if (/[\u0400-\u04ff]/.test(hint)) return "windows-1251";
const candidates = /[\u3400-\u9fff]/.test(hint)
? ["gb18030", "big5"]
: ["gb18030", "big5", "windows-1251", "windows-1252"];
let best = "gb18030",
bestScore = -Infinity;
for (const encoding of candidates) {
try {
const text = new TextDecoder(encoding).decode(bytes);
const controls = (text.match(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g) || []).length;
const replacements = (text.match(/\ufffd/g) || []).length;
const cjk = (text.match(/[\u3400-\u9fff]/g) || []).length;
const cyrillic = (text.match(/[\u0400-\u04ff]/g) || []).length;
const commonCjk = (
text.match(
/[的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经之进着等部家自理起现实都体制当本性应开合因由然前外政社义事相全与关各重新内正反明原利质向道命此变结解问意建公系军情者立代通题党程展料员革文总品活长求老基资级图统知组别期论运农指区战任处理世]/g
) || []
).length;
const score = Math.max(cjk + commonCjk * 5, cyrillic) - controls * 20 - replacements * 40;
if (score > bestScore) {
best = encoding;
bestScore = score;
}
} catch (_) {}
}
return best;
}
async function renderChapterManifest(prepared) {
const response = await prepared;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const manifest = VoiceOfMLReaderSecurity.validateChapterManifest(
JSON.parse(
await VoiceOfMLReaderSecurity.readText(response, VoiceOfMLReaderSecurity.LIMITS.manifestBytes)
)
);
assertReaderActive();
const frame = document.createElement("div");
frame.className = "epub-frame";
content.appendChild(frame);
const loaded = new Set(),
pending = new Map(),
manifestBase = trustedReaderAssetManifestUrl(chapterManifestUrl || sourceUrl);
const chapterByIndex = new Map(manifest.chapters.map((chapter) => [chapter.index, chapter]));
const chapterPrefetchCount = 6;
let chapterPreviousObserver = null;
const chapterBudget = VoiceOfMLReaderSecurity.createByteBudget();
if (!manifestBase) throw new Error("EPUB_INVALID_MANIFEST_PATH");
const searchIndex = manifest.search_index;
chapterSearchConfiguration =
searchIndex?.path === "epub-search-index.json.gz" &&
Number.isSafeInteger(searchIndex.bytes) &&
searchIndex.bytes > 0 &&
/^[0-9a-f]{64}$/.test(searchIndex.sha256 || "")
? {
chapters: manifest.chapters,
index: {
...searchIndex,
url: (() => {
const resolved = trustedChapterUrl(searchIndex.path, manifestBase);
if (!resolved) return "";
const target = new URL(resolved, location.href);
const bucketUrl = target.origin === "https://voiceofml-search.hf.space" &&
target.pathname === "/api/reader-bucket-resource"
? target.pathname + target.search : resolved;
return readerContentUrl(bucketUrl);
})()
}
}
: null;
const chapterUrl = (chapter) => trustedChapterUrl(chapter.path, manifestBase);
const chapterAt = (index) => chapterByIndex.get(index) || null;
const nextChapterNode = (index, selector) => {
for (const node of frame.querySelectorAll(selector))
if (Number(node.dataset.chapter) > index) return node;
return null;
};
const resourceUrl = (raw, base, attribute) => {
const value = String(raw || "").trim();
if (!value || value.startsWith("#")) return null;
return trustedChapterResourceUrl(value, base, attribute, manifestBase);
};
const insertChapter = (article) => {
const existing = frame.querySelector(
`.reader-epub-chapter[data-chapter="${article.dataset.chapter}"]`
);
if (existing) return existing;
const next = nextChapterNode(Number(article.dataset.chapter), ".reader-epub-chapter");
foliateScrollAnchors.preserve(() => frame.insertBefore(article, next || null));
foliateScrollAnchors.observe(article);
return article;
};
const ensureSentinel = (chapter, previous = false) => {
if (readerAbortController.signal.aborted || loaded.has(chapter.index)) return null;
const selector = `.reader-chapter-sentinel[data-chapter="${chapter.index}"]`;
const existing = frame.querySelector(selector);
if (existing) return existing;
const marker = document.createElement("button");
marker.type = "button";
marker.className = "reader-chapter-sentinel";
marker.dataset.chapter = String(chapter.index);
marker.textContent = `加载第 ${chapter.index} 章`;
marker.setAttribute("aria-label", `重试加载第 ${chapter.index} 章`);
marker.addEventListener("click", () => {
marker.disabled = true;
fetchChapter(chapter).catch((error) => {
marker.disabled = false;
console.warn("EPUB chapter could not be loaded", error);
});
});
const next = nextChapterNode(chapter.index,
".reader-epub-chapter[data-chapter], .reader-chapter-sentinel[data-chapter]");
frame.insertBefore(marker, next || null);
(previous ? chapterPreviousObserver : chapterManifestObserver).observe(marker);
return marker;
};
const chapterTarget = (article, fragment) =>
!fragment || article.id === fragment ? article :
article.querySelector(`[id="${CSS.escape(fragment)}"], [name="${CSS.escape(fragment)}"]`);
const setChapterToc = () =>
setToc(
(manifest.toc ?? manifest.chapters.map((item) => ({
title: item.title || `章节 ${item.index}`, chapter: item.index, depth: 0, fragment: ""
}))).map((item) => ({
label: item.title,
chapterIndex: item.chapter,
fragment: item.fragment,
depth: item.depth,
activate: (generation) => activateChapter(manifest.chapters[item.chapter - 1], generation, item.fragment)
}))
);
const fetchChapter = async (chapter) => {
assertReaderActive();
if (loaded.has(chapter.index)) return;
if (pending.has(chapter.index)) return pending.get(chapter.index);
const task = (async () => {
const url = chapterUrl(chapter);
if (!url) throw new Error("EPUB_INVALID_PATH");
const result = await fetchReaderUrl(url);
if (!result.ok) throw new Error(`HTTP ${result.status}`);
const bytes = await VoiceOfMLReaderSecurity.readBytes(
result,
VoiceOfMLReaderSecurity.LIMITS.chapterBytes,
chapterBudget
);
let inserted = false;
try {
assertReaderActive();
const doc = parseChapterDocument(new TextDecoder().decode(bytes));
for (const element of doc.querySelectorAll("*")) {
for (const name of ["src", "href", "poster", "xlink:href"])
if (element.hasAttribute(name)) {
const value = element.getAttribute(name) || "";
if (name === "href" && element.localName === "a" &&
trustedChapterReferenceUrl(value, url, manifestBase)) continue;
const resolved = resourceUrl(value, url, name === "href" ? "href" : "resource");
if (resolved) element.setAttribute(name, resolved);
else if (!(name === "href" && value.startsWith("#"))) element.removeAttribute(name);
}
if (element.hasAttribute("srcset")) element.removeAttribute("srcset");
}
const article = document.createElement("article");
article.className = "reader-markdown reader-epub-chapter";
article.dataset.chapter = String(chapter.index);
if (doc.body?.id) article.id = doc.body.id;
article.innerHTML = doc.body ? doc.body.innerHTML : "";
// The generated text index includes head text (e.g. the chapter title).
article._chapterSearchHead = doc.head ? document.importNode(doc.head, true) : null;
const marker = frame.querySelector(
`.reader-chapter-sentinel[data-chapter="${chapter.index}"]`
);
if (marker) {
chapterManifestObserver.unobserve(marker);
chapterPreviousObserver.unobserve(marker);
marker.remove();
}
insertChapter(article);
loaded.add(chapter.index);
inserted = true;
for (const entry of navigationState.tocEntries)
if (entry.chapterIndex === chapter.index)
entry.target = chapterTarget(article, entry.fragment);
const next = chapterAt(chapter.index + 1);
if (next) ensureSentinel(next);
const previous = chapterAt(chapter.index - 1);
if (previous) ensureSentinel(previous, true);
} catch (error) {
if (!inserted) chapterBudget.release(bytes.byteLength);
throw error;
}
})().finally(() => {
pending.delete(chapter.index);
if (!loaded.has(chapter.index)) ensureSentinel(chapter);
});
pending.set(chapter.index, task);
return task;
};
const activateChapter = async (chapter, generation, fragment = "") => {
await fetchChapter(chapter);
if (!isReaderGenerationCurrent("navigation", generation)) return false;
const node = frame.querySelector(`.reader-epub-chapter[data-chapter="${chapter.index}"]`);
let id = fragment.replace(/^#/, "");
try {
id = decodeURIComponent(id);
} catch (_) {}
const anchor = chapterTarget(node, id);
if (!anchor) throw new Error("EPUB_MISSING_ANCHOR");
foliateScrollAnchors.invalidate();
anchor.scrollIntoView({ block: "start" });
foliateScrollAnchors.remember();
syncHeadingLocation();
updateProgressTools();
scheduleSave();
return true;
};
frame.addEventListener("click", async (event) => {
const link = event.target.closest?.("a[href]");
const article = link?.closest(".reader-epub-chapter");
if (
!article ||
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
)
return;
const current = chapterAt(Number(article.dataset.chapter));
const destination = trustedChapterReferenceUrl(link.getAttribute("href"), chapterUrl(current), manifestBase);
const target = destination ? new URL(destination.url) : null;
const fragment = destination?.fragment || "";
const chapter = target && manifest.chapters.find((item) => chapterUrl(item) === target.href);
if (!chapter) {
// A stale or missing book document must not navigate out to an asset 404.
if (/\.(?:xhtml?|html?)(?:$|#)/i.test(link.getAttribute("href") || "")) {
event.preventDefault();
let message = link.nextElementSibling;
if (!message?.classList.contains("reader-chapter-link-error")) {
message = document.createElement("span");
message.className = "reader-chapter-link-error";
message.setAttribute("role", "status");
message.textContent = "(无法定位此书内链接)";
link.after(message);
}
}
return;
}
event.preventDefault();
const generation = beginReaderNavigation();
try {
await activateChapter(chapter, generation, fragment);
} catch (error) {
reportNavigationError(error, generation);
}
});
const prefetchChapters = (startIndex) => {
const tasks = manifest.chapters
.slice(startIndex, startIndex + chapterPrefetchCount)
.map((chapter) => fetchChapter(chapter).catch(() => null));
if (tasks.length) Promise.all(tasks).catch(() => {});
};
chapterManifestObserver = new IntersectionObserver(
(entries) =>
entries
.filter((entry) => entry.isIntersecting)
.forEach((entry) => {
if (readerAbortController.signal.aborted) return;
const chapter = chapterAt(Number(entry.target.dataset.chapter));
if (chapter) prefetchChapters(chapter.index - 1);
}),
{ root: viewport, rootMargin: "4000px 0px" }
);
chapterPreviousObserver = new IntersectionObserver((entries) => {
if (readerAbortController.signal.aborted) return;
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const chapter = chapterAt(Number(entry.target.dataset.chapter));
if (chapter) fetchChapter(chapter).catch(() => {});
}
}, { root: viewport });
trackReaderResource(() => {
chapterManifestObserver?.disconnect();
chapterPreviousObserver?.disconnect();
chapterManifestObserver = null;
chapterManifestLoader = null;
});
chapterManifestLoader = async (index) => {
const chapter = chapterAt(index);
if (!chapter) return null;
await fetchChapter(chapter);
return frame.querySelector(`.reader-epub-chapter[data-chapter="${index}"]`);
};
setChapterToc();
await getInitialReaderRestoration();
const savedChapter = Number(readerRestorationEntry?.chapterIndex) - 1;
const initialChapter = Number.isInteger(savedChapter)
? Math.max(0, Math.min(manifest.chapters.length - 1, savedChapter))
: 0;
if (initialChapter > 0) ensureSentinel(manifest.chapters[initialChapter - 1], true);
try {
await fetchChapter(manifest.chapters[initialChapter]);
} catch (error) {
if (readerRestorationEntry?.chapterIndex == null) throw error;
throw Object.assign(new Error("阅读位置恢复失败,原进度已保留,请重试加载。", { cause: error }), {
code: "READER_RESTORE"
});
}
prefetchChapters(initialChapter + 1);
assertReaderActive();
markReaderContentReady();
status.textContent = `EPUB · ${manifest.chapters.length} 章`;
}
async function renderDocx(prepared) {
const [response] = await prepared;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const bytes = await VoiceOfMLReaderSecurity.readBytes(
response,
VoiceOfMLReaderSecurity.LIMITS.documentBytes
);
VoiceOfMLReaderSecurity.inspectZip(bytes, {
...VoiceOfMLReaderSecurity.LIMITS,
archiveCompressedBytes: VoiceOfMLReaderSecurity.LIMITS.documentBytes
});
const styles = document.createElement("div");
styles.className = "docx-styles";
const body = document.createElement("div");
body.className = "docx-body";
content.append(styles, body);
await docx.renderAsync(bytes, body, styles, {
className: "reader-docx",
inWrapper: true,
breakPages: true,
ignoreLastRenderedPageBreak: false,
useBase64URL: true,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
renderEndnotes: true,
renderChanges: false,
renderComments: false,
renderAltChunks: false,
debug: false
});
body.classList.toggle("reader-document-dark", readerTheme === "dark");
if (!(body.textContent || "").trim() && !body.querySelector("img, table, svg, canvas"))
throw new Error("DOCX rendered no supported content");
const pages = [...body.querySelectorAll(":scope > .reader-docx-wrapper > section.reader-docx")];
if (pages.length) {
updateDocumentState({ pageCount: pages.length });
pageInput.max = String(documentState.pageCount);
document.querySelector("#page-total").textContent = `/ ${documentState.pageCount}`;
document.querySelector(".page-controls").hidden = false;
pages.forEach((page, index) => {
page.classList.add("reader-docx-page");
page.dataset.page = String(index + 1);
});
setToc(headingTocEntries(body));
}
for (const link of body.querySelectorAll("a[href]")) {
const href = link.getAttribute("href") || "";
if (!href.startsWith("#") && !/^https?:\/\//i.test(href)) link.removeAttribute("href");
else if (!href.startsWith("#")) {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
}
markReaderContentReady();
status.textContent = documentState.pageCount ? `${documentState.pageCount} 页` : "DOCX";
}
function renderImageDocument(image) {
assertReaderActive();
content.appendChild(image);
markReaderContentReady();
status.textContent = "图片";
}
function disposeFormatResources(mode) {
if (mode === "swf") {
swfPlayer?.remove();
swfPlayer = null;
}
if (["pdf", "pdf-pages"].includes(mode)) {
pdfDocument = null;
pdfPageManifest = null;
pdfOcrManifest = null;
pdfOcrManifestPromise = null;
pdfTextContentCache.clear();
pdfTextContentCacheBytes = 0;
pdfOcrPageCache.clear();
pdfOcrPageCacheBytes = 0;
clearPdfManifestPrefetches();
pdfBookSearchClient?.dispose();
pdfBookSearchClient = null;
pdfShellWindow?.dispose();
pdfFirstPagePreload?.removeAttribute("src");
pdfFirstPagePreload = null;
for (const canvas of content.querySelectorAll(".reader-page canvas")) {
canvas.width = 0;
canvas.height = 0;
}
for (const image of content.querySelectorAll(".reader-page img")) image.removeAttribute("src");
}
if (mode === "foliate") {
foliateSectionObserver?.disconnect();
foliateSectionObserver = null;
foliateSectionVirtualizer?.dispose();
foliateChapterRepository?.dispose();
foliateSectionVirtualizer = null;
foliateChapterRepository = null;
foliateSectionLoader = null;
foliateSectionSettler = null;
try {
const closing = epubRendition?.close?.();
closing?.catch?.(() => {});
} catch (_) {}
epubRendition = null;
epubBook = null;
}
if (mode === "html") {
htmlFrame?.remove();
htmlFrame = null;
content.classList.remove("reader-content--spreadsheet");
}
if (["audio", "video"].includes(mode) && mediaElement) {
mediaElement.pause();
mediaElement.removeAttribute("src");
mediaElement.load();
mediaElement = null;
}
}
function renderTextDocument(prepared) {
return renderText(false, prepared);
}
function renderMarkdownDocument(prepared) {
return renderText(true, prepared);
}
function captureChapterPosition() {
if (capability.mode !== "epub-chapters") return null;
const marker = viewport.getBoundingClientRect().top + 8;
const chapters = [...content.querySelectorAll(".reader-epub-chapter")];
const chapter =
chapters.find((node) => node.getBoundingClientRect().bottom > marker) ||
chapters[chapters.length - 1];
return chapter
? {
chapterIndex: Number(chapter.dataset.chapter),
chapterManifest: chapterManifestUrl || sourceUrl,
chapterOffset: marker - chapter.getBoundingClientRect().top
}
: null;
}
async function restoreChapterPosition(entry, generation) {
if (
/-epub-chapters-v5\//.test(chapterManifestUrl || sourceUrl) &&
entry.chapterManifest !== (chapterManifestUrl || sourceUrl)
) return false;
if (!chapterManifestLoader || !Number.isInteger(entry.chapterIndex)) return false;
const chapter = await chapterManifestLoader(entry.chapterIndex);
if (!chapter?.isConnected || !isReaderGenerationCurrent("navigation", generation)) return false;
viewport.scrollTop +=
chapter.getBoundingClientRect().top -
viewport.getBoundingClientRect().top -
8 +
(Number(entry.chapterOffset) || 0);
syncHeadingLocation();
updateProgressTools();
scheduleSave();
return true;
}
async function restoreFormat(mode, entry, generation = beginReaderNavigation()) {
try {
if (!entry || !isReaderGenerationCurrent("navigation", generation)) return false;
if (mode === "epub-chapters") {
if (Number.isInteger(entry.chapterIndex)) {
if (!(await restoreChapterPosition(entry, generation))) return false;
} else if (!(await restoreProgressState(entry, generation))) return false;
} else if (mode === "foliate" && Number.isInteger(entry.foliateSection)) {
if (!(await restoreFoliateBookmarkPosition(entry, generation))) return false;
} else if (documentState.pageCount && entry.page) {
if (
!(await goToPage(entry.page, generation)) ||
!isReaderGenerationCurrent("navigation", generation)
)
return false;
const shell = content.querySelector(
`.reader-page[data-page="${documentState.page}"], .reader-docx-page[data-page="${documentState.page}"]`
);
if (shell && Number.isFinite(entry.pageOffset))
viewport.scrollTop = shell.offsetTop + entry.pageOffset;
updateProgressTools();
scheduleSave();
} else if (!(await restoreProgressState(entry, generation))) return false;
if (!isReaderGenerationCurrent("navigation", generation)) return false;
consumeBookmarkHandoff(entry);
return true;
} catch (error) {
reportNavigationError(error, generation);
return false;
}
}
async function restoreInitialPosition(entry, generation) {
if (!entry || !isReaderGenerationCurrent("navigation", generation)) return;
const restored = await formatAdapters.active.restore(entry, generation);
if (!restored && isReaderGenerationCurrent("navigation", generation))
throw Object.assign(new Error("阅读位置恢复失败,原进度已保留,请重试加载。"), {
code: "READER_RESTORE"
});
}
async function applyInitialRestoration(entry, generation) {
updateDocumentState({ restoredEntry: entry });
if (entry?.zoom && !zoomChangedByUser && isReaderGenerationCurrent("navigation", generation))
setZoom(entry.zoom, false);
await restoreInitialPosition(entry, generation);
}
function registerReaderFormatAdapters() {
const formats = {
"pdf-pages": [
() => {
return fetchReaderResponse();
},
renderPdfPages
],
pdf: [loadPdfDocument, renderPdf],
image: [loadImageDocument, renderImageDocument],
text: [loadTextDocument, renderTextDocument],
markdown: [loadMarkdownDocument, renderMarkdownDocument],
html: [loadHtmlDocument, renderHtml],
"epub-chapters": [loadChapterManifestDocument, renderChapterManifest],
docx: [loadDocxDocument, renderDocx],
audio: [loadMediaDocument, () => renderMedia("audio")],
video: [loadMediaDocument, () => renderMedia("video")],
swf: [loadSwfDocument, renderSwf],
foliate: [loadMediaDocument, renderFoliate]
};
for (const [mode, [open, render]] of Object.entries(formats))
formatAdapters.register(mode, {
open,
render,
restore: (entry, generation) => restoreFormat(mode, entry, generation),
dispose: () => disposeFormatResources(mode)
});
}
function renderMedia(mode) {
assertReaderActive();
const media = document.createElement(mode);
media.className = mode === "audio" ? "reader-audio" : "reader-video";
media.controls = true;
media.setAttribute("aria-label", `${documentState.title} ${mode === "audio" ? "音频" : "视频"}`);
media.preload = "metadata";
if (mode === "video") media.playsInline = true;
const refresh = () => {
if (readerAbortController.signal.aborted) return;
mediaPanel.querySelector(".media-panel-time").textContent =
`${formatMediaTime(media.currentTime)} / ${formatMediaTime(media.duration)}`;
scheduleMarkerSync();
};
for (const type of ["timeupdate", "durationchange", "loadedmetadata"])
media.addEventListener(type, refresh, { passive: true });
for (const type of ["pause", "ended", "seeked"])
media.addEventListener(type, () => {
refresh();
saveProgress();
});
mediaElement = media;
let sourceRetried = false;
media.addEventListener("error", () => {
if (readerAbortController.signal.aborted) return;
if (!sourceRetried && sourceUrl !== contentUrl && validSource(sourceUrl)) {
sourceRetried = true;
media._readerSourceRetry = true;
media.src = sourceUrl;
media.load();
return;
}
media._readerSourceRetry = false;
fail("媒体加载失败,请检查网络后重试,或下载原文件。", "READER_MEDIA");
});
media.src = contentUrl;
content.appendChild(media);
markReaderContentReady();
status.textContent = mode === "audio" ? "音频" : "视频";
}
async function loadSwfDocument() {
await loadScript(RUFFLE_URL);
const response = await fetchReaderResponse();
return new Uint8Array(await response.arrayBuffer());
}
async function renderSwf(data) {
assertReaderActive();
const factory = window.RufflePlayer?.newest?.();
if (!factory) throw Object.assign(new Error("Ruffle 未加载"), { code: "READER_SWF_ENGINE" });
const player = factory.createPlayer();
player.className = "reader-swf";
player.setAttribute("aria-label", `${documentState.title} Flash 内容`);
player.style.width = "100%";
player.style.minHeight = "360px";
content.appendChild(player);
swfPlayer = player;
await player.ruffle().load({ data });
markReaderContentReady();
status.textContent = "Flash 内容";
}
// Resolve once, then keep source URLs, capability, and presentation in sync.
async function resolveReaderSource() {
if (!readerId || sourceUrl) return;
setReaderStage("resolve");
let resolved = cachedReaderData;
if (!resolved?.url) {
try {
resolved = await resolveReaderId(readerId);
} catch (error) {
assertReaderActive();
if (!localReaderData?.url) throw error;
resolved = localReaderData;
}
}
assertReaderActive();
sourceUrl = normalizeSourceUrl(resolved.url || "");
contentUrl = readerContentUrl(sourceUrl);
downloadUrl = resolved.download || sourceUrl;
chapterManifestUrl = resolved.chapter_manifest || "";
fallbackUrl = resolved.fallback || "";
if (resolved.extension) extension = String(resolved.extension).toLowerCase();
if (
extension === "pdf" &&
sourceUrl.includes("/api/reader-bucket-resource?") &&
new URL(sourceUrl).searchParams.get("path")?.endsWith("page-manifest.json")
)
extension = "pdf-pages";
capability = readerRuntime.negotiate(VoiceOfMLReader.capability(extension));
if (capability.mode !== "pdf") releasePdfWorker?.();
readerRuntime.update("source", {
url: sourceUrl,
contentUrl,
downloadUrl,
extension,
metadata: resolved
});
applyReaderMetadata(resolved);
}
async function start() {
const generation = readerRuntime.currentGeneration("navigation");
// A PDF link already tells us which engine is needed. Start its import while
// an ID-only Reader link is resolving its source URL. The promise is reused
// by loadPdfDocument, so this does not create a second module request.
if (extension === "pdf" || /\.pdf(?:$|[?#])/i.test(sourceUrl)) preloadPdfEngine().catch(() => {});
readerSourcePromise = resolveReaderSource();
await readerSourcePromise;
assertReaderActive();
if (readerId && /\/calibre-chm-epub-[^/]+\/document\.epub(?:$|\?)/i.test(sourceUrl)) {
extension = "epub";
readerRuntime.update("source", { extension });
capability = readerRuntime.negotiate(VoiceOfMLReader.capability(extension));
content.dataset.mode = capability.mode;
document.querySelector(".page-controls").hidden = true;
}
syncCapabilityControls();
downloadButton.href = readerDownloadUrl(documentState.title, downloadUrl);
if (
(!validSource(sourceUrl) && !validSource(chapterManifestUrl) && !readerId) ||
capability.readerMode === VoiceOfMLReader.ReaderMode.UNSUPPORTED
)
return fail("此文件暂不支持在线阅读,请下载原文件。", "READER_UNSUPPORTED");
formatAdapters.activate(capability.mode);
applyReaderMetadata(resolvedReaderData);
if (validOcr(ocrUrl)) {
const ocr = document.querySelector("#ocr") || document.createElement("a");
ocr.id = "ocr";
ocr.className = "text-button";
ocr.target = "_blank";
ocr.rel = "noopener noreferrer";
ocr.textContent = "OCR";
if (!ocr.parentElement) document.querySelector(".reader-actions")?.prepend(ocr);
ocr.href = ocrUrl;
ocr.hidden = false;
}
try {
if (capability.mode === "foliate") {
setReaderStage("foliate");
foliateContinuous = true;
const restorationPromise = loadReaderRestoration();
readerRestorationPromise = restorationPromise;
await awaitReader(renderFoliate());
assertReaderActive();
const restored = await awaitReader(restorationPromise);
await applyInitialRestoration(restored, generation);
initialReaderTargetSection = null;
assertReaderActive();
if (!setReaderPhase("ready")) return;
updateDocumentState({ restorationReady: !restorationFailed });
scheduleSave();
return;
}
setReaderStage("prepare");
const restorationPromise = loadReaderRestoration();
readerRestorationPromise = restorationPromise;
const prepared = await awaitReader(formatAdapters.active.open());
assertReaderActive();
await formatAdapters.active.render(prepared);
assertReaderActive();
markReaderContentReady();
const restored = await awaitReader(restorationPromise);
await applyInitialRestoration(restored, generation);
assertReaderActive();
if (!setReaderPhase("ready")) return;
updateDocumentState({ restorationReady: !restorationFailed });
schedulePdfBookSearchPrefetch();
updateProgressTools();
if (fullSearchInput.value.trim()) runFullSearch();
scheduleSave();
} catch (error) {
if (readerAbortController.signal.aborted || error?.name === "AbortError") return;
console.error(error);
if (
error?.code !== "READER_RESTORE" &&
capability.mode === "epub-chapters" &&
validFallback(fallbackUrl)
) {
const target = new URL(location.href);
target.searchParams.set("url", fallbackUrl);
target.searchParams.set("ext", "pdf");
target.searchParams.delete("chapter_manifest");
target.searchParams.delete("fallback");
if (window.parent !== window)
window.parent.postMessage({ type: "voice-reader-open", url: target.href }, location.origin);
else location.replace(target.href);
return;
}
fail(
error && error.message ? error.message : "原文件加载失败",
classifyReaderError(error, capability.mode === "foliate" ? "READER_CORRUPT" : "READER_PARSE")
);
}
}
function loadPdfTaskWithTimeout(pdfjs, options, url) {
assertReaderActive();
const task = pdfjs.getDocument(options(url));
return new Promise((resolve, reject) => {
let timeout = 0,
destroyed = false;
const destroy = () => {
if (destroyed) return;
destroyed = true;
clearTimeout(timeout);
try {
task.destroy()?.catch?.(() => {});
} catch (_) {}
reject(readerAbortError());
};
const untrack = trackReaderResource(destroy);
timeout = setTimeout(() => {
reject(new Error("reader PDF timeout"));
untrack();
destroy();
}, READER_PROXY_TIMEOUT_MS);
task.promise.then(
(document) => {
clearTimeout(timeout);
if (readerAbortController.signal.aborted) {
destroy();
return;
}
resolve(document);
},
(error) => {
clearTimeout(timeout);
reject(error);
untrack();
destroy();
}
);
});
}
function loadPdfWithTimeout(pdfjs, options) {
return retryReaderProxy(() => loadPdfTaskWithTimeout(pdfjs, options, contentUrl)).catch((error) => {
assertReaderActive();
if (error && error.name === "AbortError") throw error;
if (retryableReaderProxyError(error)) throw error;
return loadPdfTaskWithTimeout(pdfjs, options, sourceUrl);
});
}
function preloadPdfEngine() {
if (!pdfEnginePreload) pdfEnginePreload = loadPdfEngine().then(preparePdfWorker);
return pdfEnginePreload;
}
async function preparePdfWorker(pdfjs) {
assertReaderActive();
if (capability.mode !== "pdf") return pdfjs;
pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
const worker = new pdfjs.PDFWorker();
pdfWorker = worker;
let timer, destroyed = false;
const destroy = () => {
if (destroyed) return;
destroyed = true;
clearTimeout(timer);
worker.destroy();
if (pdfWorker === worker) {
pdfWorker = null;
releasePdfWorker = null;
}
};
releasePdfWorker = destroy;
const untrack = trackReaderResource(destroy);
try {
await awaitReader(Promise.race([
worker.promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("PDF worker timeout")), 20000);
})
]));
assertReaderActive();
return pdfjs;
} catch (error) {
untrack();
destroy();
assertReaderActive();
throw Object.assign(new Error("PDF 阅读组件下载失败,请检查网络后重试加载,或下载原文件。"),
{ code: "READER_ENGINE_NETWORK", cause: error });
} finally {
clearTimeout(timer);
}
}
async function loadPdfEngine() {
for (let attempt = 0; attempt < 3; attempt++) {
assertReaderActive();
if (attempt) await waitForReader(attempt * 500);
assertReaderActive();
// Browsers can retain a rejected import for the life of this document.
// Use two bounded retry URLs for the same pinned bytes, never a random URL.
const url = attempt ? `${PDFJS_URL}?reader-module-retry=${attempt}` : PDFJS_URL;
let timer;
try {
const pdfjs = await awaitReader(
Promise.race([
import(url),
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new DOMException("PDF engine timeout", "TimeoutError")),
20000
);
})
])
);
assertReaderActive();
return pdfjs;
} catch (error) {
assertReaderActive();
const networkFailure =
error?.name === "TimeoutError" ||
/failed to fetch dynamically imported module|error loading dynamically imported module|importing a module script failed/i.test(
error?.message || ""
);
if (!networkFailure) throw error;
if (attempt === 2)
throw Object.assign(
new Error("PDF 阅读组件下载失败,请检查网络后重试加载,或下载原文件。"),
{ code: "READER_ENGINE_NETWORK", cause: error }
);
} finally {
clearTimeout(timer);
}
}
}
function loadPdfDocument() {
return preloadPdfEngine().then((pdfjs) => {
assertReaderActive();
pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
const options = (url) => ({
url,
worker: pdfWorker,
// Bound demand reads for original and converted PDFs alike. Streaming
// otherwise keeps the full download running alongside these ranges.
rangeChunkSize: 262144,
disableAutoFetch: true,
disableStream: true,
wasmUrl: PDFJS_WASM_URL,
cMapUrl: PDFJS_CMAP_URL,
cMapPacked: true,
standardFontDataUrl: PDFJS_STANDARD_FONT_URL,
withCredentials: false
});
return loadPdfWithTimeout(pdfjs, options);
});
}
function loadTextDocument() {
return fetchReaderResponse().then((response) => ({ response }));
}
function loadMarkdownDocument() {
return Promise.all([
fetchReaderResponse(),
Promise.all([loadScript(MARKED_URL), loadScript(PURIFY_URL)])
]).then(([response, engines]) => ({ response, engines }));
}
function loadHtmlDocument() {
return Promise.all([fetchReaderResponse(), loadScript(PURIFY_URL)]).then(
([response, engine]) => ({ response, engine })
);
}
function loadDocxDocument() {
return Promise.all([
fetchReaderResponse(),
loadScript(JSZIP_URL).then(() => loadScript(DOCX_PREVIEW_URL))
]);
}
function loadMediaDocument() {
return Promise.resolve(null);
}
function loadImageDocument() {
assertReaderActive();
return new Promise((resolve, reject) => {
const image = new Image();
image.className = "reader-image";
image.alt = documentState.title;
image.decoding = "async";
let fallback = false,
settled = false,
timeout = 0,
untrack = () => {};
const finish = (error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
untrack();
image.onload = image.onerror = null;
if (error) {
image.removeAttribute("src");
reject(error);
} else resolve(image);
};
untrack = trackReaderResource(() => finish(readerAbortError()));
timeout = setTimeout(() => finish(new Error("image load timeout")), READER_PROXY_TIMEOUT_MS);
image.onload = () => finish();
image.onerror = () => {
if (readerAbortController.signal.aborted) return finish(readerAbortError());
if (!fallback) {
fallback = true;
image.src = sourceUrl;
} else finish(new Error("image load failed"));
};
image.src = contentUrl;
});
}
function loadChapterManifestDocument() {
return fetchReaderUrl(chapterManifestUrl || sourceUrl);
}
// Foliate keeps a source-to-rendered text mapping because CFI addresses the
// original document, before sanitization and insertion into a shadow root.
function cloneFoliateDocument(source) {
const doc = source.cloneNode(true),
original = source.createTreeWalker(source, NodeFilter.SHOW_TEXT),
cloned = doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT);
const sourceNodes = new WeakMap();
while (original.nextNode() && cloned.nextNode())
sourceNodes.set(cloned.currentNode, original.currentNode);
return { doc: sanitizeEpubDocument(doc), sourceNodes };
}
function foliateSearchAnchor(source, sourceNodes, body, renderedBody) {
const sourceWalker = body.ownerDocument.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const pairs = [];
let offset = 0;
while (sourceWalker.nextNode()) {
const node = sourceWalker.currentNode;
const original = sourceNodes.get(node);
if (original) pairs.push({ original, offset });
offset += node.length;
}
return (anchor) => {
const range = anchor(source);
if (!range || typeof range.intersectsNode !== "function") return null;
const intervals = [];
for (const { original, offset } of pairs) {
if (!range.intersectsNode(original)) continue;
const start = range.startContainer === original ? range.startOffset : 0;
const end = range.endContainer === original ? range.endOffset : original.length;
if (end > start) intervals.push({ start: offset + start, end: offset + end });
}
const walker = document.createTreeWalker(renderedBody, NodeFilter.SHOW_TEXT),
selected = [];
let offset = 0;
while (walker.nextNode()) {
const node = walker.currentNode,
end = offset + node.length;
for (const interval of intervals)
if (interval.start < end && interval.end > offset)
selected.push({
node,
start: Math.max(0, interval.start - offset),
end: Math.min(node.length, interval.end - offset)
});
offset = end;
}
return selected;
};
}
async function settleReaderImages(root) {
const images = [...root.querySelectorAll("img[src],img[srcset]")];
if (!images.length) return;
let timeout = 0;
const decoded = Promise.allSettled(
images.map((image) =>
typeof image.decode === "function"
? image.decode()
: image.complete
? Promise.resolve()
: new Promise((resolve) => {
image.addEventListener("load", resolve, { once: true });
image.addEventListener("error", resolve, { once: true });
})
)
);
try {
await awaitReader(
Promise.race([
decoded,
new Promise((resolve) => {
timeout = setTimeout(resolve, 2500);
})
])
);
} finally {
clearTimeout(timeout);
}
}
function rewriteFoliateResources(doc, section) {
const safeEmbeddedValue = (value) =>
/^(?:blob:|data:image\/(?:gif|png|jpe?g|webp);)/i.test(String(value || "").trim());
const archiveResourceUrl = (value) => {
const raw = String(value || "").trim();
if (!raw || raw.startsWith("#") || raw.includes("#") || safeEmbeddedValue(raw))
return safeEmbeddedValue(raw) ? raw : null;
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(raw)) return null;
try {
const resolved = String(
(section.resolveResourceHref
? section.resolveResourceHref(raw)
: section.resolveHref?.(raw)) || ""
);
if (
!resolved ||
/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(resolved) ||
/[\\\x00]/.test(resolved) ||
resolved.split("/").some((part) => !part || part === "." || part === "..")
)
return null;
return `/api/reader-resource?book=${encodeURIComponent(sourceUrl)}&path=${encodeURIComponent(resolved)}`;
} catch (_) {
return null;
}
};
for (const element of doc.querySelectorAll(
"img[src],source[src],audio[src],video[src],video[poster]"
)) {
for (const name of ["src", "poster"])
if (element.hasAttribute(name)) {
const safe = archiveResourceUrl(element.getAttribute(name));
if (safe) element.setAttribute(name, safe);
else element.removeAttribute(name);
}
}
for (const element of doc.querySelectorAll("[srcset]")) {
const value = element.getAttribute("srcset") || "";
const candidates = value
.split(",")
.map((part) => {
const [raw, ...descriptor] = part.trim().split(/\s+/);
const safe = archiveResourceUrl(raw);
return safe ? [safe, ...descriptor].join(" ") : null;
})
.filter(Boolean);
if (candidates.length) element.setAttribute("srcset", candidates.join(", "));
else element.removeAttribute("srcset");
}
for (const image of doc.querySelectorAll("image")) {
const value =
image.getAttributeNS("http://www.w3.org/1999/xlink", "href") ||
image.getAttribute("xlink:href") ||
image.getAttribute("href");
const safe = archiveResourceUrl(value);
if (safe) {
image.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", safe);
image.setAttribute("href", safe);
} else {
image.removeAttributeNS("http://www.w3.org/1999/xlink", "href");
image.removeAttribute("xlink:href");
image.removeAttribute("href");
}
}
for (const link of doc.querySelectorAll("a[href]")) {
const href = link.getAttribute("href") || "";
if (/\.(?:gif|png|jpe?g|webp)$/i.test(href)) {
const resource = archiveResourceUrl(href);
if (resource) {
link.setAttribute("href", resource);
link.target = "_blank";
link.rel = "noopener noreferrer";
}
}
if (/^(?:https?:|mailto:|tel:)/i.test(href)) {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
}
}
async function createFoliateSection(section, index) {
const source = await section.createDocument();
const { doc, sourceNodes } = cloneFoliateDocument(source);
assertReaderActive();
rewriteFoliateResources(doc, section);
const styles = [...doc.querySelectorAll("style,link")].map((node) => node.cloneNode(true));
for (const node of doc.querySelectorAll("style,link")) node.remove();
const article = document.createElement("article");
article.dataset.section = String(index);
const shadow = article.attachShadow({ mode: "open" }),
baseStyle = document.createElement("style"),
sectionBody = document.createElement("div");
// Chapter styles live in a shadow root, so document-level preferences cannot reach them.
baseStyle.textContent = `
:host { display: block; color: inherit; }
* { box-sizing: border-box; }
.reader-section-body { display: block; color: inherit; line-height: inherit; }
.reader-section-body > :first-child { margin-top: 0 !important; }
:host(.reader-document-dark) .reader-section-body,
:host(.reader-document-dark) .reader-section-body :where(:not(svg, svg *, img, canvas, video, audio, math, math *)) {
color: inherit !important;
background-color: transparent !important;
border-color: #4a5056 !important;
text-shadow: none !important;
}
a { color: var(--reader-book-link) !important; }
:host(.reader-document-dark) .reader-section-body a { color: var(--reader-book-link) !important; }
img, svg, video { max-width: 100%; height: auto; }
mark.full-search-highlight { background: #ffd54f; color: #111; }
:host(.reader-document-dark) .reader-section-body mark.full-search-highlight { background: #ffd54f !important; color: #111 !important; }
@media (prefers-contrast: more) {
.reader-section-body a[href] { text-decoration: underline !important; }
.reader-section-body mark.full-search-highlight { outline: 2px solid currentColor; }
}
`;
sectionBody.className = "reader-section-body";
const body = epubContentBody(doc);
// Import the sanitized nodes directly. Serializing and reparsing legacy
// nested anchors lets HTML's adoption rules reorder footnotes and citations.
if (body)
sectionBody.append(...[...body.childNodes].map((node) => document.importNode(node, true)));
article._resolveSearchAnchor = foliateSearchAnchor(source, sourceNodes, body, sectionBody);
shadow.append(baseStyle, ...styles, sectionBody);
await settleReaderImages(sectionBody);
assertReaderActive();
article.classList.toggle("reader-document-dark", readerTheme === "dark");
return article;
}
function setupFoliateWindow(stream, sections) {
const observer = new IntersectionObserver(
(entries) =>
entries
.filter((entry) => entry.isIntersecting)
.forEach((entry) => {
if (readerAbortController.signal.aborted || !foliateChapterRepository) return;
const index = Number(entry.target.dataset.section);
if (entry.target.classList.contains("foliate-section-placeholder")) {
foliateChapterRepository
.load(index)
.then(() => foliateScrollAnchors.whenIdle(trimFoliateSections))
.catch(() => {});
return;
}
const prefetchLoads = Array.from(
{ length: FOLIATE_PREFETCH_SECTIONS + 1 },
(_, offset) => index + offset
)
.filter((prefetch) => sections[prefetch])
.map((prefetch) => foliateChapterRepository.load(prefetch));
if (prefetchLoads.length)
Promise.allSettled(prefetchLoads).then(() =>
foliateScrollAnchors.whenIdle(trimFoliateSections)
);
}),
{ root: viewport, rootMargin: `${FOLIATE_PREFETCH_MARGIN}px` }
);
foliateSectionObserver = observer;
foliateChapterRepository = VoiceOfMLReaderChapters.createChapterRepository({
count: sections.length,
find: (index) =>
stream.querySelector(`article[data-section="${index}"]:not(.foliate-section-placeholder)`),
create: (index) => createFoliateSection(sections[index], index),
commit: (index, article) =>
foliateScrollAnchors.preserve(() => {
const placeholder = stream.querySelector(
`article.foliate-section-placeholder[data-section="${index}"]`
),
next = [...stream.querySelectorAll("article[data-section]")].find(
(item) => Number(item.dataset.section) > index
);
if (placeholder) {
observer.unobserve(placeholder);
placeholder.replaceWith(article);
} else stream.insertBefore(article, next || null);
observer.observe(article);
foliateScrollAnchors.observe(article);
scheduleFoliateWindowRefresh();
return article;
})
});
foliateSectionVirtualizer = VoiceOfMLReaderVirtual.createSectionVirtualizer({
limit: FOLIATE_LOADED_SECTION_LIMIT,
getLoaded: () => [
...stream.querySelectorAll("article[data-section]:not(.foliate-section-placeholder)")
],
getIndex: (article) => Number(article.dataset.section),
getHeight: (article) => article.getBoundingClientRect().height,
canVirtualize: (article) => {
const rect = article.getBoundingClientRect(),
viewportRect = viewport.getBoundingClientRect();
return (
rect.bottom < viewportRect.top - FOLIATE_PREFETCH_MARGIN ||
rect.top > viewportRect.bottom + FOLIATE_PREFETCH_MARGIN
);
},
virtualize: (article, index, height) => {
const placeholder = document.createElement("article");
placeholder.className = "foliate-section-placeholder";
placeholder.dataset.section = String(index);
placeholder.style.setProperty("--foliate-placeholder-height", `${height}px`);
placeholder.setAttribute("aria-hidden", "true");
observer.unobserve(article);
foliateScrollAnchors.unobserve(article);
article.replaceWith(placeholder);
observer.observe(placeholder);
return placeholder;
},
release: (index) => foliateChapterRepository.release(index),
preserve: (change) => foliateScrollAnchors.preserve(change)
});
foliateSectionLoader = (index) => foliateChapterRepository.load(index);
foliateSectionSettler = async () => {
assertReaderActive();
const repository = foliateChapterRepository,
indices = [
...stream.querySelectorAll("article[data-section]:not(.foliate-section-placeholder)")
].map((article) => Number(article.dataset.section));
await awaitReader(
Promise.allSettled(
indices
.flatMap((index) => [index - 1, index + 1])
.filter((index) => sections[index])
.map((index) => repository.load(index))
)
);
for (let attempt = 0; attempt < 4; attempt++) {
await waitForReader(0, true);
const tasks = repository.pending;
if (tasks.length) await awaitReader(Promise.allSettled(tasks));
}
};
}
async function foliateTocEntries(view, sections) {
const entries = [];
const append = (items, depth = 0) => {
for (const item of items || []) {
const href = item?.href ? String(item.href) : "";
if (href || item?.label)
entries.push({
label: item.label || "未命名章节",
href,
depth,
group: !href,
activate: () => view.goTo(href)
});
append(item.subitems, depth + 1);
}
};
append(view.book.toc);
await Promise.all(
entries.map(async (entry) => {
if (entry.group) {
entry.sectionIndex = -1;
entry.fragment = "";
return;
}
try {
const target = await view.book.resolveHref(entry.href),
section = target && view.book.sections[target.index];
entry.sectionIndex = section ? sections.indexOf(section) : -1;
entry.anchor = typeof target?.anchor === "function" ? target.anchor : null;
const fragment = entry.href.split("#")[1] || "";
entry.fragment = fragment ? decodeURIComponent(fragment) : "";
} catch (_) {
entry.sectionIndex = -1;
entry.fragment = "";
}
})
);
assertReaderActive();
return entries;
}
async function renderFoliate() {
const [response] = await Promise.all([
fetchReaderResponse(),
import("/static/foliate-reader/view.js")
]);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const bytes = await VoiceOfMLReaderSecurity.readBytes(
response,
VoiceOfMLReaderSecurity.LIMITS.archiveCompressedBytes
);
assertReaderActive();
if (VoiceOfMLReaderSecurity.isZipContainer(extension, bytes))
VoiceOfMLReaderSecurity.inspectZip(bytes);
const view = document.createElement("foliate-view"),
stream = document.createElement("div");
view.className = "foliate-reader-view";
stream.className = "foliate-continuous";
content.replaceChildren(loadingIndicator, view, stream);
epubRendition = view;
await view.open(
new File([bytes], new URL(sourceUrl).pathname.split("/").pop() || "book.epub", {
type: response.headers.get("content-type") || "application/epub+zip"
})
);
assertReaderActive();
epubBook = view.book;
view.style.display = "none";
const sections = view.book.sections.filter((section) => section.linear !== "no");
await getInitialReaderRestoration();
const savedSection = Number(readerRestorationEntry?.foliateSection);
const initialSection = Number.isInteger(savedSection)
? Math.max(0, Math.min(sections.length - 1, savedSection))
: 0;
initialReaderTargetSection = readerRestorationEntry?.foliateSection == null ? null : initialSection;
setupFoliateWindow(stream, sections);
if (sections[initialSection]) await foliateChapterRepository.load(initialSection);
if (initialSection > 0) await foliateChapterRepository.load(initialSection - 1);
assertReaderActive();
// Resolving every TOC href can be expensive for large books. The first
// chapter is already visible, so finish the opening path before enriching
// the navigation panel in the background.
foliateTocEntries(view, sections)
.then((entries) => {
if (entries.length && !readerAbortController.signal.aborted) setToc(entries);
})
.catch(() => {});
scheduleFoliateScrollSync();
markReaderContentReady();
status.textContent = "EPUB";
}
// Reading progress controls and undo.
const progressTools = document.createElement("div");
progressTools.className = "reader-progress-tools";
progressTools.innerHTML =
'<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>';
document.querySelector("#history-panel").appendChild(progressTools);
const readingProgressSummary = document.createElement("div");
readingProgressSummary.className = "reader-progress-summary";
readingProgressSummary.innerHTML =
'<strong>阅读状态</strong><span class="reader-progress-summary-value">0.0%</span><button class="reader-progress-bookmark text-action" type="button">在当前位置添加书签</button>';
progressTools.prepend(readingProgressSummary);
readingProgressSummary
.querySelector(".reader-progress-bookmark")
.addEventListener("click", (event) => openBookmarkPopover(event.currentTarget));
function updateReadingProgressSummary() {
const noToc = !navigationState.tocEntries.length && !mediaElement;
readingProgressSummary.hidden = !noToc;
if (noToc)
readingProgressSummary.querySelector(".reader-progress-summary-value").textContent =
`${readerProgressPercent().toFixed(1)}%`;
}
updateReadingProgressSummary();
function syncTocTab() {
const tab = document.querySelector("#toc-tab");
if (!tab) return;
tab.hidden = !navigationState.tocEntries.length;
if (navigationState.tocEntries.length && navigationState.currentChapterIndex < 0)
updateNavigationState({ currentChapterIndex: navigationState.tocEntries.findIndex((entry) => !entry.group) });
}
syncTocTab();
const progressRange = progressTools.querySelector(".reader-progress-range"),
progressPercent = progressTools.querySelector(".reader-progress-percent"),
progressUndo = progressTools.querySelector(".reader-progress-undo"),
chapterPrev = progressTools.querySelector(".reader-chapter-prev"),
chapterNext = progressTools.querySelector(".reader-chapter-next");
let previousProgressState = null,
progressSeeking = false,
epubSeekFrame = 0,
epubSeekTarget = null,
epubSeekPromise = Promise.resolve();
function updateProgressTools() {
if (progressSeeking) return;
const percent = readerProgressPercent();
progressRange.value = String(percent);
progressPercent.textContent = `${percent.toFixed(1)}%`;
const hasChapters = navigationState.tocEntries.some((entry) => !entry.group),
hasPreviousChapter = adjacentTocIndex(-1) >= 0,
hasNextChapter = adjacentTocIndex(1) >= 0;
chapterPrev.hidden = !hasChapters;
chapterNext.hidden = !hasChapters;
chapterPrev.disabled = !hasPreviousChapter;
chapterNext.disabled = !hasNextChapter;
progressUndo.hidden = !previousProgressState;
updateReadingProgressSummary();
placeReadingProgress();
}
function captureProgressState() {
return foliateContinuous
? captureFoliateBookmarkPosition()
: capability.mode === "epub-chapters"
? captureChapterPosition()
: mediaElement
? { mediaTime: mediaElement.currentTime }
: htmlFrame && htmlFrame.contentWindow
? { htmlScrollTop: htmlFrame.contentWindow.scrollY }
: { scrollTop: viewport.scrollTop };
}
async function restoreProgressState(state, generation = beginReaderNavigation()) {
if (!state || !isReaderGenerationCurrent("navigation", generation)) return false;
if (foliateContinuous && Number.isInteger(state.foliateSection))
return restoreFoliateBookmarkPosition(state, generation);
if (capability.mode === "epub-chapters" && Number.isInteger(state.chapterIndex))
return restoreChapterPosition(state, generation);
try {
if (mediaElement && Number.isFinite(state.mediaTime)) {
const media = mediaElement;
if (!media.readyState)
await new Promise((resolve, reject) => {
const finish = (error) => {
media.removeEventListener("loadedmetadata", loaded);
media.removeEventListener("error", failed);
untrack();
if (error) reject(error);
else resolve();
};
const loaded = () => finish(),
failed = () => {
if (!media._readerSourceRetry) finish(new Error("Media metadata unavailable"));
};
const untrack = trackReaderResource(() => finish(readerAbortError()));
media.addEventListener("loadedmetadata", loaded, { once: true });
media.addEventListener("error", failed);
});
if (!isReaderGenerationCurrent("navigation", generation)) return false;
media.currentTime = state.mediaTime;
} else if (htmlFrame && htmlFrame.contentWindow && Number.isFinite(state.htmlScrollTop))
htmlFrame.contentWindow.scrollTo(0, state.htmlScrollTop);
else if (Number.isFinite(state.scrollTop)) viewport.scrollTop = state.scrollTop;
updateProgressTools();
scheduleSave();
return true;
} catch (error) {
reportNavigationError(error, generation);
return false;
}
}
function flushEpubSeek() {
if (epubSeekFrame) {
cancelAnimationFrame(epubSeekFrame);
epubSeekFrame = 0;
epubSeekPromise = seekFoliateProgress(epubSeekTarget.percent, epubSeekTarget.generation);
}
return epubSeekPromise;
}
function seekProgress(value, preserve = false) {
if (!preserve || !previousProgressState) previousProgressState = captureProgressState();
const generation = beginReaderNavigation(),
percent = Math.max(0, Math.min(100, Number(value)));
if (!isReaderGenerationCurrent("navigation", generation)) return;
if (mediaElement && mediaElement.duration)
mediaElement.currentTime = (mediaElement.duration * percent) / 100;
else if (foliateContinuous) {
epubSeekTarget = { percent, generation };
epubSeekFrame = requestAnimationFrame(() => {
epubSeekFrame = 0;
epubSeekPromise = seekFoliateProgress(percent, generation);
});
} else if (htmlFrame && htmlFrame.contentWindow)
htmlFrame.contentWindow.scrollTo(
0,
(Math.max(
0,
htmlFrame.contentDocument.documentElement.scrollHeight - htmlFrame.contentWindow.innerHeight
) *
percent) /
100
);
else
viewport.scrollTop =
(Math.max(0, viewport.scrollHeight - viewport.clientHeight) * percent) / 100;
updateProgressTools();
scheduleSave();
}
async function activateChapter(index) {
if (
index < 0 ||
index >= navigationState.tocEntries.length ||
index === navigationState.currentChapterIndex
)
return;
try {
await navigateTocEntry(index);
} finally {
updateProgressTools();
}
}
function adjacentTocIndex(direction) {
for (let index = navigationState.currentChapterIndex + direction;
index >= 0 && index < navigationState.tocEntries.length; index += direction) {
if (!navigationState.tocEntries[index].group) return index;
}
return -1;
}
progressRange.addEventListener("pointerdown", () => {
beginReaderNavigation();
progressSeeking = true;
previousProgressState = captureProgressState();
});
progressRange.addEventListener("input", () => {
progressPercent.textContent = `${Number(progressRange.value).toFixed(1)}%`;
seekProgress(progressRange.value, true);
});
for (const type of ["pointerup", "pointercancel"])
progressRange.addEventListener(type, () => {
progressSeeking = false;
const generation = readerRuntime.currentGeneration("navigation");
flushEpubSeek().then(() => {
if (isReaderGenerationCurrent("navigation", generation)) updateProgressTools();
});
});
progressUndo.addEventListener("click", () => {
const state = previousProgressState;
previousProgressState = null;
progressSeeking = false;
restoreProgressState(state);
updateProgressTools();
});
chapterPrev.addEventListener("click", () =>
activateChapter(adjacentTocIndex(-1))
);
chapterNext.addEventListener("click", () =>
activateChapter(adjacentTocIndex(1))
);
const fullSearchView = document.createElement("section");
fullSearchView.id = "full-search-view";
fullSearchView.className = "reader-panel-view full-search-view";
fullSearchView.dataset.panelView = "full-search";
fullSearchView.hidden = true;
fullSearchView.innerHTML =
'<div class="full-search-bar"><input id="full-search-input" class="full-search-input" type="search" placeholder="搜索正文" aria-label="搜索正文"><button id="full-search-clear" class="icon-button" type="button" aria-label="清除全文搜索" title="清除">×</button></div><div id="full-search-status" class="full-search-status" role="status">输入关键词搜索正文</div><div class="full-search-nav"><button id="full-search-prev" type="button" disabled>上一个</button><button id="full-search-next" type="button" disabled>下一个</button></div><div id="full-search-results" class="full-search-results"></div>';
document
.querySelector("#history-panel")
.insertBefore(fullSearchView, document.querySelector(".reader-panel-tabs"));
const fullSearchButton = document.querySelector("#full-search-toggle");
fullSearchButton.textContent = "全文搜索";
fullSearchButton.setAttribute("aria-label", "全文搜索");
fullSearchButton.hidden = !capability.features.search || (capability.mode === "pdf-pages" && !ocrManifestUrl);
const fullSearchInput = fullSearchView.querySelector("#full-search-input"),
fullSearchStatus = fullSearchView.querySelector("#full-search-status"),
fullSearchResultsNode = fullSearchView.querySelector("#full-search-results"),
fullSearchClear = fullSearchView.querySelector("#full-search-clear");
let fullSearchInputTimer = 0;
fullSearchClear.hidden = true;
function updateFullSearchButton(open) {
fullSearchButton.textContent = open ? "关闭全文搜索" : "全文搜索";
fullSearchButton.setAttribute("aria-label", fullSearchButton.textContent);
}
function updateFullSearchClear() {
fullSearchClear.hidden = !fullSearchInput.value;
}
const chapterSearchPagination = document.createElement("div");
chapterSearchPagination.className = "full-search-pagination";
chapterSearchPagination.hidden = true;
chapterSearchPagination.innerHTML =
'<button id="full-search-page-prev" type="button">上一页</button><label>第 <input id="full-search-page" type="number" min="1" value="1" aria-label="搜索结果页码"> 页 <span id="full-search-pages"></span></label><button id="full-search-page-next" type="button">下一页</button>';
fullSearchResultsNode.before(chapterSearchPagination);
const fullSearchCancel = document.createElement("button");
fullSearchCancel.type = "button";
fullSearchCancel.id = "full-search-cancel";
fullSearchCancel.className = "text-action";
fullSearchCancel.textContent = "取消搜索";
fullSearchCancel.hidden = true;
fullSearchStatus.after(fullSearchCancel);
const fullSearchRetry = document.createElement("button");
fullSearchRetry.type = "button";
fullSearchRetry.id = "full-search-retry";
fullSearchRetry.className = "text-action";
fullSearchRetry.textContent = "重试搜索";
fullSearchRetry.hidden = true;
fullSearchCancel.after(fullSearchRetry);
fullSearchRetry.addEventListener("click", async () => {
const failed = failedSearchPage;
if (!failed || searchState.query !== failed.query) return runFullSearch();
if (chapterSearchClient?.failed) {
await runFullSearch();
if (!fullSearchComplete || searchState.query !== failed.query) return;
}
return loadChapterSearchPage(failed.offset, failed.selectLast);
});
fullSearchCancel.addEventListener("click", () => {
nextReaderGeneration("search");
beginReaderNavigation();
chapterSearchClient?.cancel();
pdfBookSearchClient?.cancel();
pdfSearchInProgress = false;
chapterSearchInProgress = false;
chapterSearchPage = null;
failedSearchPage = null;
pdfSearchPageLoader = null;
fullSearchComplete = false;
updateSearchState({ results: [], index: -1 });
renderFullSearchResults();
fullSearchCancel.hidden = true;
fullSearchRetry.hidden = false;
fullSearchRetry.textContent = "重试搜索";
fullSearchStatus.textContent = "搜索已取消";
});
chapterSearchPagination
.querySelector("#full-search-page-prev")
.addEventListener("click", () =>
loadChapterSearchPage(chapterSearchPage.offset - chapterSearchPage.pageSize)
);
chapterSearchPagination
.querySelector("#full-search-page-next")
.addEventListener("click", () =>
loadChapterSearchPage(chapterSearchPage.offset + chapterSearchPage.pageSize)
);
chapterSearchPagination.querySelector("#full-search-page").addEventListener("change", (event) => {
if (!chapterSearchPage || !Number.isFinite(event.target.valueAsNumber)) return;
const page = Math.max(1, Math.min(
Math.ceil(chapterSearchPage.total / chapterSearchPage.pageSize),
Math.floor(event.target.valueAsNumber)
));
const offset = (page - 1) * chapterSearchPage.pageSize;
if (offset !== chapterSearchPage.offset) loadChapterSearchPage(offset);
});
// Full-text search: format matching, result presentation, and navigation.
function fullSearchPageOffset(total, requestedOffset, pageSize = 50) {
return Math.max(0, Math.min(
Math.floor(Math.max(0, total - 1) / pageSize),
Math.floor(requestedOffset / pageSize)
)) * pageSize;
}
function chapterSearchModule() {
if (!chapterSearchModulePromise)
chapterSearchModulePromise = import("/static/reader-chapter-search.mjs").catch((error) => {
chapterSearchModulePromise = null;
throw error;
});
return chapterSearchModulePromise;
}
async function searchChapterBook(query, generation) {
if (!chapterSearchConfiguration) throw new Error("本书暂无完整全文搜索索引");
const module = await chapterSearchModule();
if (!isReaderGenerationCurrent("search", generation)) return;
if (chapterSearchClient?.failed) {
chapterSearchClient.dispose();
chapterSearchClient = null;
}
if (!chapterSearchClient)
chapterSearchClient = module.createChapterSearch(chapterSearchConfiguration);
const page = await chapterSearchClient.search(query, partial => {
if (!isReaderGenerationCurrent("search", generation)) return;
applyChapterSearchPage(partial);
renderFullSearchResults();
fullSearchStatus.textContent = partial.total
? `${partial.total} 个结果(正在搜索… ${partial.scanned} / ${partial.chapters} 章)`
: `正在搜索正文… ${partial.scanned} / ${partial.chapters} 章`;
});
if (isReaderGenerationCurrent("search", generation)) applyChapterSearchPage(page);
}
function applyChapterSearchPage(page) {
chapterSearchPage = page;
updateSearchState({
results: page.results.map((result) => ({
...result,
activate: (generation) => navigateChapterSearchResult(result, generation)
})),
index: -1
});
}
async function navigateChapterSearchResult(result, generation) {
const article = await chapterManifestLoader?.(result.chapterIndex);
if (!article || !isReaderGenerationCurrent("navigation", generation)) return false;
const module = await chapterSearchModule();
if (!isReaderGenerationCurrent("navigation", generation)) return false;
clearFullSearchMarks();
const mapping = module.chapterTextMap([article._chapterSearchHead, article]);
const match = result.snippet.text.slice(
result.snippet.matchStart,
result.snippet.matchStart + result.snippet.matchLength
);
let start = result.start;
if (mapping.text.slice(start, start + result.length) !== match) {
// A browser may repair malformed source HTML differently from HTMLParser.
// Relocate only by a unique full excerpt; never guess between repeated hits.
const found = mapping.text.indexOf(result.snippet.text);
if (found < 0 || mapping.text.indexOf(result.snippet.text, found + 1) >= 0)
throw new Error("已加载章节,但未能准确定位这处文字");
start = found + result.snippet.matchStart;
}
const parts = mapping.parts(start, start + result.length, article);
const [mark] = highlightTextParts(parts);
(mark || article).scrollIntoView({ block: "center" });
const tocIndex = navigationState.tocEntries.findIndex(
(entry) => entry.chapterIndex === result.chapterIndex
);
if (tocIndex >= 0) {
updateNavigationState({ currentChapterIndex: tocIndex });
updateTocCurrentMark();
}
updateProgressTools();
scheduleSave();
return true;
}
function updateChapterSearchPagination() {
const page = chapterSearchPage;
chapterSearchPagination.hidden = !page || !page.total;
if (!page) return;
const pages = Math.ceil(page.total / page.pageSize);
const input = chapterSearchPagination.querySelector("#full-search-page");
input.value = String(Math.floor(page.offset / page.pageSize) + 1);
input.max = String(Math.max(1, pages));
input.style.setProperty("--page-width", `${input.max.length}ch`);
chapterSearchPagination.querySelector("#full-search-pages").textContent = `/ ${pages}`;
chapterSearchPagination.querySelector("#full-search-page-prev").disabled =
pdfSearchInProgress || chapterSearchInProgress || page.offset === 0;
chapterSearchPagination.querySelector("#full-search-page-next").disabled =
pdfSearchInProgress || chapterSearchInProgress || page.offset + page.pageSize >= page.total;
input.disabled = pdfSearchInProgress || chapterSearchInProgress;
}
async function loadChapterSearchPage(offset, selectLast = null) {
if (pdfSearchInProgress || chapterSearchInProgress || !chapterSearchPage ||
(!chapterSearchClient && !pdfSearchPageLoader)) return;
failedSearchPage = null;
const generation = nextReaderGeneration("search");
beginReaderNavigation();
if (!["foliate", "epub-chapters"].includes(capability.mode)) fullSearchCancel.hidden = false;
fullSearchRetry.hidden = true;
fullSearchStatus.textContent = `${chapterSearchPage.total} 个结果`;
try {
const pdfLoader = pdfSearchPageLoader;
const page = await (pdfLoader ? pdfLoader(offset, generation) : chapterSearchClient.page(offset));
if (!isReaderGenerationCurrent("search", generation)) return;
if (pdfLoader) {
chapterSearchPage = page;
updateSearchState({ results: page.results, index: -1 });
} else applyChapterSearchPage(page);
renderFullSearchResults();
fullSearchStatus.textContent = `${page.total} 个结果`;
if (selectLast !== null)
await activateFullSearchResult(selectLast ? page.results.length - 1 : 0, false);
} catch (error) {
if (isReaderGenerationCurrent("search", generation)) {
fullSearchStatus.textContent = error.message || "搜索结果加载失败";
failedSearchPage = { offset, selectLast, query: searchState.query };
fullSearchRetry.textContent = "重试本页";
fullSearchRetry.hidden = false;
}
} finally {
if (isReaderGenerationCurrent("search", generation)) fullSearchCancel.hidden = true;
}
}
async function fullSearchFoliateMatches(query, generation) {
if (!epubRendition?.search) return [];
const groups = [], firstPage = [];
// Cache only visited pages for this query; never retain every match in the book.
const cachedPages = new Map();
let total = 0;
const toResult = (item, label, occurrence) => {
const excerpt = item.excerpt || { pre: "", match: query, post: "" };
return {
location: label || "电子书位置",
cfi: item.cfi,
occurrence,
snippet: {
text: `${excerpt.pre}${excerpt.match}${excerpt.post}`,
matchStart: excerpt.pre.length,
matchLength: excerpt.match.length,
prefix: "",
suffix: ""
}
};
};
pdfSearchPageLoader = async (requestedOffset, pageGeneration = readerRuntime.currentGeneration("search")) => {
const pageSize = 50;
const offset = fullSearchPageOffset(total, requestedOffset, pageSize);
if (!offset) return { total, offset, pageSize, results: firstPage.slice() };
const cached = cachedPages.get(offset);
if (cached) {
cachedPages.delete(offset);
cachedPages.set(offset, cached);
return cached;
}
const results = [];
let passed = 0;
for (const group of groups) {
if (passed + group.count <= offset) { passed += group.count; continue; }
const sectionOffset = Math.max(0, offset - passed);
const limit = Math.min(pageSize - results.length, group.count - sectionOffset);
for await (const batch of epubRendition.search({
query, index: group.index, offset: sectionOffset, limit, lean: true,
current: () => isReaderGenerationCurrent("search", pageGeneration)
}))
for (const [index, item] of batch.subitems.entries())
results.push(toResult(item, group.label, sectionOffset + index));
passed += group.count;
if (results.length === pageSize) break;
}
const page = { total, offset, pageSize, results };
if (isReaderGenerationCurrent("search", pageGeneration) && JSON.stringify(page).length <= 128 * 1024) {
cachedPages.set(offset, page);
if (cachedPages.size > 4) cachedPages.delete(cachedPages.keys().next().value);
}
return page;
};
for await (const group of epubRendition.search({
query, lean: true, limit: 50,
current: () => isReaderGenerationCurrent("search", generation)
})) {
if (!isReaderGenerationCurrent("search", generation)) return [];
if (group.count) groups.push({ index: group.index, count: group.count, label: group.label });
for (const [index, item] of group.subitems.entries()) {
if (firstPage.length === 50) break;
firstPage.push(toResult(item, group.label, index));
}
total += group.count;
chapterSearchPage = { total, offset: 0, pageSize: 50, results: firstPage.slice() };
updateSearchState({ results: chapterSearchPage.results, index: -1 });
renderFullSearchResults();
fullSearchStatus.textContent = `${total} 个结果(正在搜索…)`;
await waitForReader();
if (!isReaderGenerationCurrent("search", generation)) return [];
}
chapterSearchPage = { total, offset: 0, pageSize: 50, results: firstPage.slice() };
return firstPage;
}
function fullSearchEscape(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
let fullSearchActiveMarks = [];
function clearFullSearchMarks() {
content.querySelectorAll(".reader-text-hit-box").forEach(node => node.remove());
const changedParents = new Set();
for (const mark of fullSearchActiveMarks) {
if (mark.tagName === "MARK") {
if (mark.parentNode) changedParents.add(mark.parentNode);
mark.replaceWith(mark.textContent);
}
else mark.classList.remove("full-search-highlight");
}
fullSearchActiveMarks = [];
for (const parent of changedParents) parent.normalize();
}
async function fullSearchTextNodes(root, generation) {
const nodes = [],
walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let visited = 0;
while (walker.nextNode()) {
if (!isReaderGenerationCurrent("search", generation)) return null;
const node = walker.currentNode;
if (
node.data &&
!node.parentElement.closest("script,style,mark.full-search-highlight,.reader-pdf-text")
)
nodes.push(node);
if (++visited % 1000 === 0) await waitForReader();
}
return nodes;
}
function highlightTextParts(parts) {
const marks = [];
for (const { node, start, end } of [...parts].reverse()) {
node.splitText(end);
const selected = node.splitText(start),
mark = node.ownerDocument.createElement("mark");
mark.className = "full-search-highlight";
mark.textContent = selected.data;
selected.replaceWith(mark);
fullSearchActiveMarks.push(mark);
marks.unshift(mark);
}
return marks;
}
function fullSearchSnippet(text, index, length) {
const start = Math.max(0, index - 72),
end = Math.min(text.length, index + length + 88);
return {
text: text.slice(start, end),
matchStart: index - start,
matchLength: length,
prefix: start ? "…" : "",
suffix: end < text.length ? "…" : ""
};
}
function fullSearchSnippetDom(snippet) {
const node = document.createElement("span");
node.className = "full-search-snippet";
if (snippet.prefix) node.append(snippet.prefix);
node.append(snippet.text.slice(0, snippet.matchStart));
const mark = document.createElement("mark");
mark.className = "search-match";
mark.textContent = snippet.text.slice(
snippet.matchStart,
snippet.matchStart + snippet.matchLength
);
node.append(mark, snippet.text.slice(snippet.matchStart + snippet.matchLength));
if (snippet.suffix) node.append(snippet.suffix);
return node;
}
function fullSearchLocation(node, fallback, offset, total) {
const page = node.parentElement && node.parentElement.closest("[data-page]");
if (page) return `第 ${page.dataset.page} 页`;
if (navigationState.tocEntries.length) {
const heading = [...navigationState.tocEntries].reverse().find(entry => entry.target &&
(entry.target === node.parentElement || !!(entry.target.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING)));
if (heading) return heading.label;
}
return `正文 ${Math.min(100, Math.floor(offset / Math.max(1, total) * 100))}%`;
}
async function fullSearchDomMatches(root, fallback, query, generation, progressive = false,
occurrence = null, navigationGeneration = readerRuntime.currentGeneration("navigation")) {
root = foliateSectionRoot(root);
const nodes = await fullSearchTextNodes(root, generation);
if (!nodes || !isReaderGenerationCurrent("search", generation)) return [];
const offsets = [],
chunks = [];
let length = 0;
for (const [index, node] of nodes.entries()) {
offsets.push({ node, start: length, end: length + node.data.length });
chunks.push(node.data);
length += node.data.length;
if (index % 1000 === 999) {
await waitForReader();
if (!isReaderGenerationCurrent("search", generation)) return [];
}
}
const text = chunks.join(""), groups = [], firstHits = [];
const patternText = fullSearchEscape(query);
let nextStart = 0, total = 0;
const locateIndex = (index) => {
let low = 0, high = offsets.length - 1;
while (low < high) {
const middle = (low + high) >> 1;
if (offsets[middle].end <= index) low = middle + 1;
else high = middle;
}
return low;
};
const partsFor = (start, end) => {
const parts = [];
for (let index = locateIndex(start); index < offsets.length && offsets[index].start < end; index++)
parts.push(offsets[index]);
return parts;
};
const matchesIn = function* (group) {
const re = new RegExp(patternText, "giu");
re.lastIndex = group.skip;
for (const match of text.slice(group.start, group.end).matchAll(re)) {
if (match.index >= 65536) break;
yield { index: group.start + match.index, value: match[0] };
}
};
const resultFor = (match) => {
const part = offsets[locateIndex(match.index)];
return {
location: fullSearchLocation(part.node, fallback, match.index, text.length),
snippet: fullSearchSnippet(text, match.index, match.value.length),
async activate(navigationGeneration = readerRuntime.currentGeneration("navigation")) {
if (!isReaderGenerationCurrent("navigation", navigationGeneration)) return false;
const searchGeneration = readerRuntime.currentGeneration("search");
clearFullSearchMarks();
const currentNodes = await fullSearchTextNodes(root, searchGeneration);
if (!currentNodes || !isReaderGenerationCurrent("search", searchGeneration) ||
!isReaderGenerationCurrent("navigation", navigationGeneration)) return false;
let position = 0, parts = [];
const end = match.index + match.value.length;
for (const node of currentNodes) {
const nodeEnd = position + node.data.length;
if (nodeEnd > match.index && position < end)
parts.push({ node, start: Math.max(0, match.index - position), end: Math.min(node.data.length, end - position) });
position = nodeEnd;
if (position >= end) break;
}
if (parts.map(part => part.node.data.slice(part.start, part.end)).join("") !== match.value) return false;
const [target] = highlightTextParts(parts);
target?.scrollIntoView({ block: "center" });
return !!target;
}
};
};
const page = (requestedOffset) => {
const pageSize = 50, results = [];
const offset = fullSearchPageOffset(total, requestedOffset, pageSize);
let passed = 0;
for (const group of groups) {
if (passed + group.count <= offset) { passed += group.count; continue; }
for (const match of matchesIn(group)) {
if (passed++ < offset) continue;
results.push(resultFor(match));
if (results.length === pageSize) break;
}
if (results.length === pageSize) break;
}
return { total, offset, pageSize, results };
};
for (let start = 0; start < text.length; start += 65536) {
const group = { start, end: start + 65536 + query.length - 1,
skip: Math.max(0, nextStart - start), count: 0 };
for (const match of matchesIn(group)) {
group.count++;
nextStart = match.index + match.value.length;
if (firstHits.length < 100) firstHits.push(match);
}
groups.push(group);
total += group.count;
if (progressive && (start === 0 || (group.count && total <= 50) || groups.length % 8 === 0)) {
pdfSearchPageLoader = page;
chapterSearchPage = page(0);
updateSearchState({ results: chapterSearchPage.results, index: -1 });
renderFullSearchResults();
fullSearchStatus.textContent = `${total} 个结果(正在搜索…)`;
}
await waitForReader();
if (!isReaderGenerationCurrent("search", generation)) return [];
}
if (progressive) {
pdfSearchPageLoader = page;
chapterSearchPage = page(0);
}
if (occurrence !== null) {
if (!isReaderGenerationCurrent("navigation", navigationGeneration)) return [];
const matchedPage = page(occurrence);
const result = matchedPage.results[occurrence - matchedPage.offset];
if (!result || !await result.activate(navigationGeneration)) return [];
return [{ target: fullSearchActiveMarks[0] }];
}
const fallbackResults = progressive ? null : firstHits.map(resultFor);
if (navigationGeneration === readerRuntime.currentGeneration("navigation")) {
for (let i = firstHits.length - 1; i >= 0; i--) {
const match = firstHits[i], end = match.index + match.value.length;
const parts = partsFor(match.index, end);
if (!parts.length) continue;
const [target] = highlightTextParts(parts.map(part => ({ node: part.node,
start: Math.max(match.index, part.start) - part.start,
end: Math.min(end, part.end) - part.start })));
if (fallbackResults) fallbackResults[i].target = target;
}
}
return fallbackResults || page(0).results;
}
async function navigateFoliateSearchResult(result, generation) {
const resolved = await epubRendition?.resolveNavigation?.(result.cfi);
if (!resolved || !isReaderGenerationCurrent("navigation", generation)) return false;
const section = epubBook?.sections?.[resolved.index],
sections = epubBook?.sections?.filter((item) => item.linear !== "no") || [],
visibleIndex = section ? sections.indexOf(section) : -1;
if (visibleIndex < 0 || !foliateSectionLoader) return false;
let node = await foliateSectionLoader(visibleIndex);
if (!isReaderGenerationCurrent("navigation", generation)) return false;
if (foliateSectionSettler) await foliateSectionSettler();
if (!isReaderGenerationCurrent("navigation", generation)) return false;
if (!node?.isConnected) node = await foliateSectionLoader(visibleIndex);
if (!node?.isConnected || !isReaderGenerationCurrent("navigation", generation)) return false;
clearFullSearchMarks();
let target;
if (typeof resolved.anchor === "function" && node._resolveSearchAnchor) {
const parts = node._resolveSearchAnchor(resolved.anchor);
if (!parts?.length) return false;
[target] = highlightTextParts(parts);
} else {
// Some non-CFI engines expose only a section locator.
const matches = await fullSearchDomMatches(
foliateSectionRoot(node),
"电子书位置",
searchState.query,
readerRuntime.currentGeneration("search"),
false,
result.occurrence || 0,
generation
);
target = matches[0]?.target;
}
if (!isReaderGenerationCurrent("navigation", generation) || !target) return false;
const targetRect = target.getBoundingClientRect(),
viewportRect = viewport.getBoundingClientRect();
foliateScrollAnchors.invalidate();
viewport.scrollTop = Math.max(
0,
viewport.scrollTop +
targetRect.top -
viewportRect.top -
(viewport.clientHeight - targetRect.height) / 2
);
const tocIndex = navigationState.tocEntries.findIndex(
(entry) => entry.sectionIndex === visibleIndex
);
if (tocIndex >= 0) {
updateNavigationState({ currentChapterIndex: tocIndex });
updateTocCurrentMark();
}
updateProgressTools();
scheduleSave();
return true;
}
let pdfSearchPageLoader = null;
let pdfSearchInProgress = false;
async function ensurePdfBookSearchClient(manifest) {
if (!manifest?.book_text?.path) throw new Error("本书集中全文尚未生成");
if (!pdfBookSearchModulePromise)
pdfBookSearchModulePromise = import("/static/reader-pdf-book-search.mjs").catch(error => {
pdfBookSearchModulePromise = null;
throw error;
});
const module = await pdfBookSearchModulePromise;
if (pdfBookSearchClient?.failed) {
pdfBookSearchClient.dispose();
pdfBookSearchClient = null;
}
if (!pdfBookSearchClient)
pdfBookSearchClient = module.createPdfBookSearch({
url: pdfOcrSource().assetUrl(manifest.book_text.path),
sha256: manifest.book_text.sha256,
bytes: manifest.book_text.bytes,
pageCount: documentState.pageCount,
sourceSha: manifest.source_sha256
});
return pdfBookSearchClient;
}
async function prefetchPdfBookSearch() {
if (pdfBookSearchPrefetchPromise || !ocrManifestUrl ||
!["pdf", "pdf-pages"].includes(capability.mode) || navigator.connection?.saveData) return;
const connection = navigator.connection;
if (["slow-2g", "2g"].includes(connection?.effectiveType)) return;
pdfBookSearchPrefetchPromise = (async () => {
const manifest = await loadPdfOcrManifest();
const bytes = Number(manifest?.book_text?.bytes);
if (!Number.isSafeInteger(bytes) || bytes < 1 || bytes > PDF_BOOK_SEARCH_PREFETCH_MAX_BYTES) return;
const client = await ensurePdfBookSearchClient(manifest);
await client.prefetch();
})().catch(() => {});
}
function schedulePdfBookSearchPrefetch() {
if (pdfBookSearchPrefetchTimer || pdfBookSearchPrefetchPromise || !ocrManifestUrl ||
!["pdf", "pdf-pages"].includes(capability.mode) || navigator.connection?.saveData) return;
pdfBookSearchPrefetchTimer = setTimeout(() => {
pdfBookSearchPrefetchTimer = 0;
const idle = window.requestIdleCallback || ((callback) => setTimeout(callback, 0));
idle(() => prefetchPdfBookSearch(), { timeout: 1500 });
}, PDF_BOOK_SEARCH_PREFETCH_DELAY);
}
async function searchConcentratedPdf(query, generation) {
const manifest = await loadPdfOcrManifest();
const client = await ensurePdfBookSearchClient(manifest);
let firstPageResults = [];
const attach = (page) => {
const results = page.results.map((hit, position) => {
const previous = page.offset === 0 ? firstPageResults[position] : null;
if (previous?.page === hit.page && previous.start === hit.start && previous.length === hit.length)
return previous;
return { ...hit, location: `第 ${hit.page} 页`, activate: async nav => {
if (!await goToPage(hit.page, nav) || !isReaderGenerationCurrent("navigation", nav))
return false;
content.querySelectorAll(".reader-text-hit-box").forEach(node => node.remove());
const shell = content.querySelector(`.reader-page[data-page="${hit.page}"]`);
if (shell && hit.boxes.length) {
paintTextHit(shell, hit.boxes);
shell.querySelector(".reader-text-hit-box")?.scrollIntoView({ block: "center" });
}
return true;
} };
});
if (page.offset === 0) firstPageResults = results;
return { ...page, results };
};
pdfSearchPageLoader = async offset => attach(await client.page(offset));
const page = await client.search(query, partial => {
if (!isReaderGenerationCurrent("search", generation)) return;
chapterSearchPage = attach(partial);
updateSearchState({ results: chapterSearchPage.results, index: -1 });
renderFullSearchResults();
fullSearchStatus.textContent = partial.total
? `${partial.total} 个结果(正在搜索… ${partial.scanned} / ${partial.pages} 页)`
: `正在搜索正文… ${partial.scanned} / ${partial.pages} 页`;
});
if (!isReaderGenerationCurrent("search", generation)) return [];
chapterSearchPage = attach(page);
return chapterSearchPage.results;
}
function makePdfSearchPageLoader(groups, total, pattern, firstPage) {
const pdf = pdfDocument;
return async (requestedOffset, generation = readerRuntime.currentGeneration("search")) => {
const pageSize = 50, results = [];
const offset = fullSearchPageOffset(total, requestedOffset, pageSize);
if (!offset) return { total, offset, pageSize, results: firstPage.slice() };
let skipped = 0;
for (const group of groups) {
if (skipped + group.count <= offset) {
skipped += group.count;
continue;
}
const text = await loadPdfSearchText(pdf, group.page, generation);
let examined = 0;
for (const match of text.matchAll(pattern)) {
if (++examined % 2048 === 0) {
await waitForReader();
if (!isReaderGenerationCurrent("search", generation))
throw new DOMException("Search cancelled", "AbortError");
}
if (skipped++ < offset) continue;
results.push({
location: `第 ${group.page} 页`,
snippet: fullSearchSnippet(text, match.index, match[0].length),
activate: (navigationGeneration) => goToPage(group.page, navigationGeneration)
});
if (results.length === pageSize) break;
}
if (results.length === pageSize) break;
}
return { total, offset, pageSize, results };
};
}
function publishPdfSearchProgress(generation, groups, total, pattern, firstPage, scanned, pages) {
if (!isReaderGenerationCurrent("search", generation)) return false;
pdfSearchPageLoader = makePdfSearchPageLoader(groups, total, pattern, firstPage);
chapterSearchPage = { total, offset: 0, pageSize: 50, results: firstPage.slice() };
updateSearchState({ results: chapterSearchPage.results, index: -1 });
renderFullSearchResults();
fullSearchStatus.textContent = total
? `${total} 个结果(正在搜索… ${scanned} / ${pages} 页)`
: `正在搜索正文… ${scanned} / ${pages} 页,暂未找到匹配`;
return true;
}
const pdfSearchTextCache = new Map();
const PDF_SEARCH_TEXT_CACHE_BYTES = 8 * 1024 * 1024;
let pdfSearchTextCacheBytes = 0;
function cachePdfSearchText(page, text) {
const bytes = text.length * 2;
if (bytes > PDF_SEARCH_TEXT_CACHE_BYTES) return;
const previous = pdfSearchTextCache.get(page);
if (previous !== undefined) {
pdfSearchTextCacheBytes -= previous.length * 2;
pdfSearchTextCache.delete(page);
}
pdfSearchTextCache.set(page, text);
pdfSearchTextCacheBytes += bytes;
while (pdfSearchTextCacheBytes > PDF_SEARCH_TEXT_CACHE_BYTES) {
const oldest = pdfSearchTextCache.keys().next().value;
pdfSearchTextCacheBytes -= pdfSearchTextCache.get(oldest).length * 2;
pdfSearchTextCache.delete(oldest);
}
}
trackReaderResource(() => {
pdfSearchTextCache.clear();
pdfSearchTextCacheBytes = 0;
});
async function loadPdfSearchText(pdf, page, generation) {
if (!isReaderGenerationCurrent("search", generation))
throw new DOMException("Search cancelled", "AbortError");
let text = pdfSearchTextCache.get(page);
if (text !== undefined) {
pdfSearchTextCache.delete(page);
pdfSearchTextCache.set(page, text);
return text;
}
const pdfPage = await awaitReader(pdf.getPage(page));
if (!isReaderGenerationCurrent("search", generation))
throw new DOMException("Search cancelled", "AbortError");
const textContent = await awaitReader(pdfPage.getTextContent());
if (!isReaderGenerationCurrent("search", generation))
throw new DOMException("Search cancelled", "AbortError");
text = pdfSearchText(pdfTextContent(textContent.items)).text;
cachePdfSearchText(page, text);
return text;
}
async function fullSearchPdfMatches(query, generation) {
const pdf = pdfDocument,
groups = [], firstPage = [],
pattern = new RegExp(fullSearchEscape(pdfSearchText(query).text), "giu");
if (!pdf) return [];
let total = 0, hasText = false;
for (let page = 1; page <= pdf.numPages; page++) {
if (!isReaderGenerationCurrent("search", generation)) return [];
const text = await loadPdfSearchText(pdf, page, generation);
hasText ||= !!text.trim();
let count = 0;
for (const match of text.matchAll(pattern)) {
count++;
if (firstPage.length < 50) firstPage.push({
location: `第 ${page} 页`,
snippet: fullSearchSnippet(text, match.index, match[0].length),
activate: (navigationGeneration) => goToPage(page, navigationGeneration)
});
if (count % 2048 === 0) {
await waitForReader();
if (!isReaderGenerationCurrent("search", generation)) return [];
}
}
if (count) {
groups.push({ page, count });
total += count;
highlightPdfText(content.querySelector(`.reader-page[data-page="${page}"]`), query);
}
if (page === 1 || (count && total <= 50) || page % 8 === 0 || page === pdf.numPages) {
if (!publishPdfSearchProgress(generation, groups, total, pattern, firstPage, page, pdf.numPages)) return [];
await waitForReader(0, true);
}
}
if (!hasText) throw new Error("此 PDF 尚无可搜索文字层,需要完成 OCR 后才能搜索正文");
publishPdfSearchProgress(generation, groups, total, pattern, firstPage, pdf.numPages, pdf.numPages);
return chapterSearchPage.results;
}
let fullSearchRenderedResults = [];
let fullSearchRenderedOffset = -1;
function renderFullSearchResults() {
updateChapterSearchPagination();
fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => {
button.disabled = !searchState.results.length;
});
const offset = chapterSearchPage?.offset || 0;
if (fullSearchRenderedOffset === offset &&
fullSearchResultsNode.childElementCount === searchState.results.length &&
fullSearchRenderedResults.length === searchState.results.length &&
searchState.results.every((result, index) => result === fullSearchRenderedResults[index])) return;
const fragment = document.createDocumentFragment();
for (const [index, result] of searchState.results.entries()) {
const row = document.createElement("button"),
location = document.createElement("small");
row.type = "button";
row.className = "full-search-result";
location.className = "full-search-location";
location.textContent = result.location;
const rank = document.createElement("span");
rank.className = "full-search-rank";
rank.textContent = `${offset + index + 1}.`;
row.append(rank, location, fullSearchSnippetDom(result.snippet));
row.addEventListener("click", () => activateFullSearchResult(index));
fragment.appendChild(row);
}
fullSearchResultsNode.replaceChildren(fragment);
fullSearchRenderedResults = searchState.results.slice();
fullSearchRenderedOffset = offset;
}
function closeFullSearchView() {
clearTimeout(fullSearchInputTimer);
fullSearchInputTimer = 0;
if (pdfSearchInProgress || chapterSearchInProgress) fullSearchComplete = false;
nextReaderGeneration("search");
if (chapterSearchInProgress) chapterSearchClient?.cancel();
if (pdfSearchInProgress) pdfBookSearchClient?.cancel();
pdfSearchInProgress = false;
chapterSearchInProgress = false;
fullSearchCancel.hidden = true;
fullSearchRetry.hidden = true;
selectPanel(mediaElement ? "media" : "toc");
updateFullSearchButton(false);
}
async function runFullSearch() {
clearTimeout(fullSearchInputTimer);
fullSearchInputTimer = 0;
const query = fullSearchInput.value.trim();
fullSearchComplete = false;
failedSearchPage = null;
const generation = nextReaderGeneration("search");
pdfBookSearchClient?.cancel();
beginReaderNavigation();
clearFullSearchMarks();
updateSearchState({ query, results: [], index: -1 });
chapterSearchPage = null;
pdfSearchPageLoader = null;
updateChapterSearchPagination();
fullSearchRetry.hidden = true;
fullSearchRetry.textContent = "重试搜索";
fullSearchCancel.hidden = !["epub-chapters", "pdf", "pdf-pages", "foliate"].includes(capability.mode) || !query;
pdfSearchInProgress = ["pdf", "pdf-pages", "foliate"].includes(capability.mode) && !!query;
chapterSearchInProgress = capability.mode === "epub-chapters" && !!query;
fullSearchResultsNode.textContent = "";
fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => {
button.disabled = true;
});
if (!query) {
chapterSearchClient?.cancel();
fullSearchStatus.textContent = "输入关键词搜索正文";
return;
}
fullSearchStatus.textContent = "正在搜索正文…";
try {
if (capability.mode === "pdf" && !ocrManifestUrl)
await fullSearchPdfMatches(query, generation);
else if (["pdf", "pdf-pages"].includes(capability.mode))
publishSearchResults(generation, await searchConcentratedPdf(query, generation));
else if (capability.mode === "foliate")
publishSearchResults(generation, await fullSearchFoliateMatches(query, generation));
else if (capability.mode === "epub-chapters") await searchChapterBook(query, generation);
else if (["text", "markdown", "docx"].includes(capability.mode))
publishSearchResults(
generation,
await fullSearchDomMatches(content, "阅读位置", query, generation, true)
);
else if (capability.mode === "html")
publishSearchResults(
generation,
htmlFrame?.contentDocument?.body
? await fullSearchDomMatches(
htmlFrame.contentDocument.body,
"HTML 阅读位置",
query,
generation,
true
)
: []
);
else throw new Error("此格式没有可搜索文本");
if (!isReaderGenerationCurrent("search", generation)) return;
fullSearchComplete = true;
pdfSearchInProgress = false;
chapterSearchInProgress = false;
renderFullSearchResults();
fullSearchStatus.textContent = chapterSearchPage
? chapterSearchPage.total
? `${chapterSearchPage.total} 个结果`
: "未找到正文匹配"
: searchState.results.length
? `${searchState.results.length}${searchState.results.length === 100 ? "+" : ""} 个结果`
: "未找到正文匹配";
} catch (error) {
if (isReaderGenerationCurrent("search", generation)) {
pdfSearchInProgress = false;
chapterSearchInProgress = false;
fullSearchStatus.textContent = error.message || "正文搜索不可用";
fullSearchRetry.hidden = !["epub-chapters", "pdf", "pdf-pages", "foliate"].includes(capability.mode);
}
} finally {
if (isReaderGenerationCurrent("search", generation))
pdfSearchInProgress = false;
if (isReaderGenerationCurrent("search", generation)) {
chapterSearchInProgress = false;
updateChapterSearchPagination();
}
if (isReaderGenerationCurrent("search", generation)) fullSearchCancel.hidden = true;
if (isReaderGenerationCurrent("search", generation))
fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => {
button.disabled = !searchState.results.length;
});
}
}
async function activateFullSearchResult(index, closePanel = true) {
const result = searchState.results[index];
if (!result) return;
const previousIndex = searchState.index;
const panelSerial = readerPanelInteractionSerial;
const searchGeneration = readerRuntime.currentGeneration("search");
const generation = beginReaderNavigation();
updateSearchState({ index });
try {
const activated = result.cfi
? await navigateFoliateSearchResult(result, generation)
: await result.activate(generation);
if (activated === false && isReaderGenerationCurrent("navigation", generation) &&
isReaderGenerationCurrent("search", searchGeneration) && searchState.results[index] === result) {
updateSearchState({ index: previousIndex });
fullSearchStatus.textContent = "搜索结果定位失败,请重试";
return;
}
if (
activated === false ||
!isReaderGenerationCurrent("navigation", generation) ||
searchState.results[index] !== result
)
return;
if (fullSearchStatus.textContent === "搜索结果定位失败,请重试")
fullSearchStatus.textContent = `${chapterSearchPage?.total ?? searchState.results.length} 个结果`;
if (closePanel && panelSerial === readerPanelInteractionSerial &&
isReaderGenerationCurrent("search", searchGeneration))
setReaderPanelOpen(false, true);
else if (!closePanel) fullSearchResultsNode.children[index]?.scrollIntoView({ block: "nearest" });
} catch (error) {
if (error?.name !== "AbortError" && isReaderGenerationCurrent("navigation", generation) &&
isReaderGenerationCurrent("search", searchGeneration) && searchState.results[index] === result) {
updateSearchState({ index: previousIndex });
fullSearchStatus.textContent = capability.mode === "epub-chapters"
? error.message || "搜索结果定位失败,请重试"
: "搜索结果定位失败,请重试";
}
reportNavigationError(error, generation);
}
}
function moveFullSearch(step) {
if (chapterSearchPage?.total) {
const page = chapterSearchPage;
const next = searchState.index < 0 ? (step > 0 ? 0 : -1) : searchState.index + step;
if (next < 0)
return loadChapterSearchPage(
page.offset
? page.offset - page.pageSize
: Math.floor((page.total - 1) / page.pageSize) * page.pageSize,
true
);
if (next >= searchState.results.length)
return loadChapterSearchPage(
page.offset + page.pageSize < page.total ? page.offset + page.pageSize : 0,
false
);
return activateFullSearchResult(next, false);
}
if (searchState.results.length)
return activateFullSearchResult(
(searchState.index + step + searchState.results.length) % searchState.results.length,
false
);
}
function toggleFullSearch() {
const panel = document.querySelector("#history-panel");
if (panel.classList.contains("is-open") && !fullSearchView.hidden) {
closeFullSearchView();
return;
}
setReaderPanelOpen(true);
selectPanel("full-search");
updateFullSearchButton(true);
updateFullSearchClear();
fullSearchInput.focus();
if (searchState.query !== fullSearchInput.value.trim() || !fullSearchComplete) runFullSearch();
else if (failedSearchPage) fullSearchRetry.hidden = false;
}
fullSearchButton.addEventListener("click", toggleFullSearch);
function queueFullSearch() {
clearTimeout(fullSearchInputTimer);
nextReaderGeneration("search");
beginReaderNavigation();
chapterSearchClient?.cancel();
pdfBookSearchClient?.cancel();
fullSearchComplete = false;
pdfSearchInProgress = false;
chapterSearchInProgress = false;
chapterSearchPage = null;
failedSearchPage = null;
pdfSearchPageLoader = null;
updateSearchState({ results: [], index: -1 });
renderFullSearchResults();
fullSearchCancel.hidden = true;
fullSearchRetry.hidden = true;
fullSearchStatus.textContent = fullSearchInput.value.trim() ? "正在搜索正文…" : "输入关键词搜索正文";
fullSearchInputTimer = setTimeout(runFullSearch, 180);
}
fullSearchInput.addEventListener("input", (event) => {
updateFullSearchClear();
if (!event.isComposing) queueFullSearch();
});
fullSearchInput.addEventListener("focus", () => { readerPanelInteractionSerial++; });
fullSearchInput.addEventListener("compositionend", queueFullSearch);
fullSearchView.querySelector("#full-search-clear").addEventListener("click", () => {
fullSearchInput.value = "";
updateFullSearchClear();
runFullSearch();
fullSearchInput.focus();
});
fullSearchView
.querySelector("#full-search-prev")
.addEventListener("click", () => moveFullSearch(-1));
fullSearchView
.querySelector("#full-search-next")
.addEventListener("click", () => moveFullSearch(1));
function syncCapabilityControls() {
content.dataset.mode = capability.mode || "unsupported";
document.querySelector(".page-controls").hidden = !capability.features.pagination;
document.querySelector(".zoom-controls").hidden = !capability.features.zoom;
mediaTab.hidden = !capability.features.media;
fullSearchButton.hidden = !capability.features.search || (capability.mode === "pdf-pages" && !ocrManifestUrl);
bookmarkRibbon.hidden = !capability.features.bookmarks;
readingProgressSummary.querySelector(".reader-progress-bookmark").hidden =
!capability.features.bookmarks;
if (
(panelState.selected === "media" && mediaTab.hidden) ||
(panelState.selected === "full-search" && fullSearchButton.hidden)
)
selectPanel("toc");
}
registerReaderFormatAdapters();
syncCapabilityControls();
updateProgressTools();
start().catch((error) => {
if (readerAbortController.signal.aborted || error?.name === "AbortError") return;
console.error(error);
fail("阅读文件解析失败,请刷新后重试,或下载原文件。", classifyReaderError(error));
});