export function createReaderChapterFormat(readerContext) { async function renderChapterManifest(prepared) { const response = await prepared; if (!response.ok) throw new Error(`HTTP ${response.status}`); const manifest = VoiceOfMLReaderSecurity.validateChapterManifest( JSON.parse( await VoiceOfMLReaderSecurity.readText(response, VoiceOfMLReaderSecurity.LIMITS.manifestBytes) ) ); readerContext.assertReaderActive(); const frame = document.createElement("div"); frame.className = "epub-frame"; frame.style.overflowAnchor = "none"; const openingGeneration = readerContext.readerRuntime.currentGeneration("navigation"); readerContext.content.appendChild(frame); const loaded = new Set(), retainedBytes = new Map(), manifestBase = readerContext.trustedReaderAssetManifestUrl(readerContext.chapterManifestUrl || readerContext.sourceUrl); const chapterByIndex = new Map(manifest.chapters.map((chapter) => [chapter.index, chapter])); const chapterPrefetchCount = 6; const chapterPrefetchMaxCount = 10, chapterPrefetchBytes = 128 * 1024; let chapterFocusIndex = 1, chapterWindowFrame = 0, chapterWindowReady = false; let chapterNavigating = 0; let chapterPreviousObserver = null; const chapterBudget = VoiceOfMLReaderSecurity.createByteBudget(); if (!manifestBase) throw new Error("EPUB_INVALID_MANIFEST_PATH"); const searchIndex = manifest.search_index; readerContext.chapterSearchConfiguration = searchIndex?.path === "epub-search-index.json.gz" && Number.isSafeInteger(searchIndex.bytes) && searchIndex.bytes > 0 && /^[0-9a-f]{64}$/.test(searchIndex.sha256 || "") ? { chapters: manifest.chapters, index: { ...searchIndex, url: (() => { const resolved = readerContext.trustedChapterUrl(searchIndex.path, manifestBase); if (!resolved) return ""; const target = new URL(resolved, location.href); const bucketUrl = target.origin === "https://voiceofml-search.hf.space" && target.pathname === "/api/reader-bucket-resource" ? target.pathname + target.search : resolved; return readerContext.readerContentUrl(bucketUrl); })() } } : null; const chapterUrl = (chapter) => readerContext.trustedChapterUrl(chapter.path, manifestBase); const chapterAt = (index) => chapterByIndex.get(index) || null; const nextChapterNode = (index, selector) => { for (const node of frame.querySelectorAll(selector)) if (Number(node.dataset.chapter) > index) return node; return null; }; const resourceUrl = (raw, base, attribute) => { const value = String(raw || "").trim(); if (!value || value.startsWith("#")) return null; return readerContext.trustedChapterResourceUrl(value, base, attribute, manifestBase); }; const insertChapter = (article) => { const existing = frame.querySelector( `.reader-epub-chapter[data-chapter="${article.dataset.chapter}"]` ); if (existing) return existing; const next = nextChapterNode(Number(article.dataset.chapter), ".reader-epub-chapter, .reader-chapter-sentinel"); const marker = frame.querySelector(`.reader-chapter-sentinel[data-chapter="${article.dataset.chapter}"]`); const insert = () => { if (marker) { readerContext.chapterManifestObserver.unobserve(marker); chapterPreviousObserver.unobserve(marker); marker.replaceWith(article); } else frame.insertBefore(article, next || null); }; const preservePosition = chapterWindowReady || !readerContext.isReaderGenerationCurrent("navigation", openingGeneration); if (preservePosition) readerContext.foliateScrollAnchors.preserve(insert); else insert(); readerContext.chapterManifestObserver.observe(article); if (preservePosition) readerContext.foliateScrollAnchors.observe(article); return article; }; const ensureSentinel = (chapter, previous = false) => { if (readerContext.readerAbortController.signal.aborted || loaded.has(chapter.index)) return null; const selector = `.reader-chapter-sentinel[data-chapter="${chapter.index}"]`; const existing = frame.querySelector(selector); if (existing) return existing; const marker = document.createElement("button"); marker.type = "button"; marker.className = "reader-chapter-sentinel"; marker.dataset.chapter = String(chapter.index); marker.textContent = `加载第 ${chapter.index} 章`; marker.setAttribute("aria-label", `重试加载第 ${chapter.index} 章`); marker.addEventListener("click", () => { marker.disabled = true; fetchChapter(chapter).catch((error) => { marker.disabled = false; console.warn("EPUB chapter could not be loaded", error); }); }); const next = nextChapterNode(chapter.index, ".reader-epub-chapter[data-chapter], .reader-chapter-sentinel[data-chapter]"); const insert = () => frame.insertBefore(marker, next || null); if (chapterWindowReady || !readerContext.isReaderGenerationCurrent("navigation", openingGeneration)) readerContext.foliateScrollAnchors.preserve(insert); else insert(); (previous ? chapterPreviousObserver : readerContext.chapterManifestObserver).observe(marker); return marker; }; const chapterTarget = (article, fragment) => !fragment || article.id === fragment ? article : article.querySelector(`[id="${CSS.escape(fragment)}"], [name="${CSS.escape(fragment)}"]`); const setChapterToc = () => readerContext.setToc( (manifest.toc ?? manifest.chapters.map((item) => ({ title: item.title || `章节 ${item.index}`, chapter: item.index, depth: 0, fragment: "" }))).map((item) => ({ label: item.title, chapterIndex: item.chapter, fragment: item.fragment, depth: item.depth, activate: (generation) => activateChapter(manifest.chapters[item.chapter - 1], generation, item.fragment) })) ); const chapterScheduler = VoiceOfMLReaderChapters.createChapterScheduler({ create: async (index, signal, priority) => { const chapter = chapterAt(index); readerContext.assertReaderActive(); const url = chapterUrl(chapter); if (!url) throw new Error("EPUB_INVALID_PATH"); const result = await readerContext.fetchReaderUrl(url, { signal, priority }); if (!result.ok) throw new Error(`HTTP ${result.status}`); const bytes = await VoiceOfMLReaderSecurity.readBytes( result, VoiceOfMLReaderSecurity.LIMITS.chapterBytes, chapterBudget ); let inserted = false; try { readerContext.assertReaderActive(); signal.throwIfAborted(); const doc = readerContext.parseChapterDocument(new TextDecoder().decode(bytes)); for (const element of doc.querySelectorAll("*")) { for (const name of ["src", "href", "poster", "xlink:href"]) if (element.hasAttribute(name)) { const value = element.getAttribute(name) || ""; if (name === "href" && element.localName === "a" && readerContext.trustedChapterReferenceUrl(value, url, manifestBase)) continue; const resolved = resourceUrl(value, url, name === "href" ? "href" : "resource"); if (resolved) element.setAttribute(name, resolved); else if (!(name === "href" && value.startsWith("#"))) element.removeAttribute(name); } if (element.hasAttribute("srcset")) element.removeAttribute("srcset"); } const article = document.createElement("article"); article.className = "reader-markdown reader-epub-chapter"; article.dataset.chapter = String(chapter.index); if (doc.body?.id) article.id = doc.body.id; article.innerHTML = doc.body ? doc.body.innerHTML : ""; for (const image of article.querySelectorAll("img")) { image.loading = priority === "high" ? "eager" : "lazy"; image.fetchPriority = priority; image.decoding = "async"; } // The generated text index includes head text (e.g. the chapter title). article._chapterSearchHead = doc.head ? document.importNode(doc.head, true) : null; insertChapter(article); loaded.add(chapter.index); retainedBytes.set(chapter.index, bytes.byteLength); inserted = true; for (const entry of readerContext.navigationState.tocEntries) if (entry.chapterIndex === chapter.index) entry.target = chapterTarget(article, entry.fragment); const next = chapterAt(chapter.index + 1); if (next) ensureSentinel(next); const previous = chapterAt(chapter.index - 1); if (previous) ensureSentinel(previous, true); scheduleChapterWindow(); } catch (error) { if (!inserted) chapterBudget.release(bytes.byteLength); throw error; } } }); const fetchChapter = (chapter, demand = true, exclusive = false) => { readerContext.assertReaderActive(); if (loaded.has(chapter.index)) return Promise.resolve(); return chapterScheduler.load(chapter.index, demand, exclusive).then(() => { readerContext.foliateScrollAnchors.whenIdle(trimChapterWindow); }).catch(error => { if (error.name !== "AbortError" && !readerContext.readerAbortController.signal.aborted) { const marker = ensureSentinel(chapter); marker?.classList.add("reader-chapter-load-error"); marker?.removeAttribute("aria-hidden"); } throw error; }); }; const activateChapter = async (chapter, generation, fragment = "") => { chapterFocusIndex = chapter.index; chapterNavigating++; try { await fetchChapter(chapter, true, chapterWindowReady); } finally { chapterNavigating--; } if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false; const node = frame.querySelector(`.reader-epub-chapter[data-chapter="${chapter.index}"]`); let id = fragment.replace(/^#/, ""); try { id = decodeURIComponent(id); } catch (_) {} const anchor = chapterTarget(node, id); if (!anchor) throw new Error("EPUB_MISSING_ANCHOR"); readerContext.foliateScrollAnchors.invalidate(); anchor.scrollIntoView({ block: "start" }); readerContext.foliateScrollAnchors.remember(); readerContext.syncHeadingLocation(); readerContext.updateProgressTools(); readerContext.scheduleSave(); return true; }; frame.addEventListener("click", async (event) => { const link = event.target.closest?.("a[href]"); const article = link?.closest(".reader-epub-chapter"); if ( !article || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey ) return; const current = chapterAt(Number(article.dataset.chapter)); const destination = readerContext.trustedChapterReferenceUrl(link.getAttribute("href"), chapterUrl(current), manifestBase); const target = destination ? new URL(destination.url) : null; const fragment = destination?.fragment || ""; const chapter = target && manifest.chapters.find((item) => chapterUrl(item) === target.href); if (!chapter) { // A stale or missing book document must not navigate out to an asset 404. if (/\.(?:xhtml?|html?)(?:$|#)/i.test(link.getAttribute("href") || "")) { event.preventDefault(); let message = link.nextElementSibling; if (!message?.classList.contains("reader-chapter-link-error")) { message = document.createElement("span"); message.className = "reader-chapter-link-error"; message.setAttribute("role", "status"); message.textContent = "(无法定位此书内链接)"; link.after(message); } } return; } event.preventDefault(); const generation = readerContext.beginReaderNavigation(); try { await activateChapter(chapter, generation, fragment); } catch (error) { readerContext.reportNavigationError(error, generation); } }); const chapterVirtualizer = VoiceOfMLReaderVirtual.createSectionVirtualizer({ limit: 12, getLoaded: () => [...frame.querySelectorAll(":scope > .reader-epub-chapter")], getIndex: article => Number(article.dataset.chapter), getHeight: article => article.getBoundingClientRect().height, canVirtualize: article => { const rect = article.getBoundingClientRect(), view = readerContext.viewport.getBoundingClientRect(); const selection = window.getSelection(); return (rect.bottom < view.top - 800 || rect.top > view.bottom + 800) && !article.contains(document.activeElement) && !article.querySelector("mark.full-search-highlight") && !(selection && !selection.isCollapsed && selection.containsNode(article, true)); }, virtualize: (article, index, height) => { loaded.delete(index); const marker = ensureSentinel(chapterAt(index)); const style = getComputedStyle(article); marker.classList.add("reader-chapter-placeholder"); marker.style.height = `${height}px`; marker.style.marginTop = style.marginTop; marker.style.marginBottom = style.marginBottom; marker.setAttribute("aria-hidden", "true"); readerContext.chapterManifestObserver.unobserve(article); readerContext.foliateScrollAnchors.unobserve(article); for (const entry of readerContext.navigationState.tocEntries) if (entry.chapterIndex === index) entry.target = null; for (const image of article.querySelectorAll("img")) image.removeAttribute("src"); article.replaceWith(marker); article._chapterSearchHead = null; }, release: index => { chapterBudget.release(retainedBytes.get(index)); retainedBytes.delete(index); }, preserve: change => readerContext.foliateScrollAnchors.preserve(change) }); function trimChapterWindow() { if (chapterWindowReady) chapterVirtualizer.trim(chapterFocusIndex); } function chapterPrefetchIndices() { if (document.hidden) return []; // A fixed opening prefix keeps extra front matter within the DOM retention target. const forwardCount = Math.max(chapterPrefetchCount, chapterPrefetchMaxCount + 1 - chapterFocusIndex); const nearby = manifest.chapters.slice(Math.max(0, chapterFocusIndex - 2), chapterFocusIndex + forwardCount); const indices = []; let bytes = 0; for (const chapter of nearby) { bytes += chapter.bytes; if (chapter.index > chapterFocusIndex + chapterPrefetchCount && bytes > chapterPrefetchBytes) break; if (!loaded.has(chapter.index)) indices.push(chapter.index); } return indices; } function refreshChapterWindow() { chapterWindowFrame = 0; if (!chapterWindowReady || readerContext.readerAbortController.signal.aborted) return; const view = readerContext.viewport.getBoundingClientRect(); const rows = [...frame.querySelectorAll(":scope > [data-chapter]")]; const current = rows.find(row => { const rect = row.getBoundingClientRect(); return rect.top <= view.top + 80 && rect.bottom > view.top + 80; }) || rows.find(row => row.getBoundingClientRect().top > view.top); if (current && !chapterNavigating) chapterFocusIndex = Number(current.dataset.chapter); if (!chapterNavigating && current?.classList.contains("reader-chapter-sentinel") && current.getBoundingClientRect().top < view.bottom) fetchChapter(chapterAt(chapterFocusIndex)).catch(() => {}); if (!chapterNavigating) chapterScheduler.prefetch(chapterPrefetchIndices()); readerContext.foliateScrollAnchors.whenIdle(trimChapterWindow); } function scheduleChapterWindow() { if (!chapterWindowFrame) chapterWindowFrame = requestAnimationFrame(refreshChapterWindow); } function handleChapterVisibility() { if (document.hidden) chapterScheduler.prefetch([]); else scheduleChapterWindow(); } readerContext.chapterManifestObserver = new IntersectionObserver( scheduleChapterWindow, { root: readerContext.viewport, rootMargin: "800px 0px" } ); chapterPreviousObserver = new IntersectionObserver((entries) => { if (!chapterWindowReady || chapterNavigating || readerContext.readerAbortController.signal.aborted) return; for (const entry of entries) { if (!entry.isIntersecting) continue; const chapter = chapterAt(Number(entry.target.dataset.chapter)); if (chapter) fetchChapter(chapter).catch(() => {}); } }, { root: readerContext.viewport }); readerContext.viewport.addEventListener("scroll", scheduleChapterWindow, { passive: true }); window.addEventListener("resize", scheduleChapterWindow); document.addEventListener("visibilitychange", handleChapterVisibility); let chapterCleaned = false; readerContext.chapterManifestCleanup = () => { if (chapterCleaned) return; chapterCleaned = true; chapterWindowReady = false; cancelAnimationFrame(chapterWindowFrame); readerContext.viewport.removeEventListener("scroll", scheduleChapterWindow); window.removeEventListener("resize", scheduleChapterWindow); document.removeEventListener("visibilitychange", handleChapterVisibility); chapterScheduler.dispose(); chapterVirtualizer.dispose(); readerContext.chapterManifestObserver?.disconnect(); chapterPreviousObserver?.disconnect(); readerContext.chapterManifestObserver = null; readerContext.chapterManifestLoader = null; for (const bytes of retainedBytes.values()) chapterBudget.release(bytes); retainedBytes.clear(); loaded.clear(); }; readerContext.trackReaderResource(readerContext.chapterManifestCleanup); readerContext.chapterManifestLoader = async (index) => { const chapter = chapterAt(index); if (!chapter) return null; chapterFocusIndex = index; chapterNavigating++; try { await fetchChapter(chapter, true, chapterWindowReady); } finally { chapterNavigating--; } return frame.querySelector(`.reader-epub-chapter[data-chapter="${index}"]`); }; readerContext.chapterManifestLoader.count = manifest.chapters.length; setChapterToc(); await readerContext.getInitialReaderRestoration(); const savedChapter = Number(readerContext.readerRestorationEntry?.chapterIndex) - 1; const initialChapter = Number.isInteger(savedChapter) ? Math.max(0, Math.min(manifest.chapters.length - 1, savedChapter)) : 0; chapterFocusIndex = manifest.chapters[initialChapter].index; if (initialChapter > 0) ensureSentinel(manifest.chapters[initialChapter - 1], true); try { const firstChapter = fetchChapter(manifest.chapters[initialChapter]); chapterScheduler.prefetch(chapterPrefetchIndices()); await firstChapter; } catch (error) { if (readerContext.readerRestorationEntry?.chapterIndex == null) throw error; throw Object.assign(new Error("阅读位置恢复失败,原进度已保留,请重试加载。", { cause: error }), { code: "READER_RESTORE" }); } chapterWindowReady = true; if (readerContext.isReaderGenerationCurrent("navigation", openingGeneration)) readerContext.foliateScrollAnchors.invalidate(); for (const article of frame.querySelectorAll(".reader-epub-chapter")) readerContext.foliateScrollAnchors.observe(article); frame.style.overflowAnchor = "none"; scheduleChapterWindow(); readerContext.assertReaderActive(); readerContext.markReaderContentReady(); readerContext.status.textContent = `EPUB · ${manifest.chapters.length} 章`; } function captureChapterPosition() { if (readerContext.capability.mode !== "epub-chapters") return null; const marker = readerContext.viewport.getBoundingClientRect().top + 8; const chapters = [...readerContext.content.querySelectorAll(".reader-epub-chapter")]; const chapter = chapters.find((node) => node.getBoundingClientRect().bottom > marker) || chapters[chapters.length - 1]; return chapter ? { chapterIndex: Number(chapter.dataset.chapter), chapterManifest: readerContext.chapterManifestUrl || readerContext.sourceUrl, chapterOffset: marker - chapter.getBoundingClientRect().top } : null; } async function restoreChapterPosition(entry, generation) { if ( /-epub-chapters-v5\//.test(readerContext.chapterManifestUrl || readerContext.sourceUrl) && entry.chapterManifest !== (readerContext.chapterManifestUrl || readerContext.sourceUrl) ) return false; if (!readerContext.chapterManifestLoader || !Number.isInteger(entry.chapterIndex)) return false; const chapter = await readerContext.chapterManifestLoader(entry.chapterIndex); if (!chapter?.isConnected || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false; readerContext.viewport.scrollTop += chapter.getBoundingClientRect().top - readerContext.viewport.getBoundingClientRect().top - 8 + (Number(entry.chapterOffset) || 0); readerContext.foliateScrollAnchors.remember(); readerContext.syncHeadingLocation(); readerContext.updateProgressTools(); readerContext.scheduleSave(); return true; } return { renderChapterManifest, captureChapterPosition, restoreChapterPosition }; }