| const PDFJS_URL = "/static/vendor/pdf.min.e0be3863c23c.mjs"; |
| 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 EPUB_URL = "/static/vendor/epub.min.06eae1574510.js"; |
| const MARKED_URL = "/static/vendor/marked.min.eaccee2fb9fb.js"; |
| const PURIFY_URL = "/static/vendor/purify.min.c2f26ea4fc0d.js"; |
| const JSZIP_URL = "/static/vendor/jszip.min.acc7e41455a8.js"; |
| const DOCX_PREVIEW_URL = "/static/vendor/docx-preview.min.3573b8d99344.js"; |
| 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; |
| }; |
| } |
| const params = new URLSearchParams(location.search); |
| const sourceUrl = params.get("url") || ""; |
| const contentUrl = `/api/reader-content?url=${encodeURIComponent(sourceUrl)}`; |
| const downloadUrl = params.get("download") || sourceUrl; |
| const extension = (params.get("ext") || "").toLowerCase(); |
| const capability = VoiceOfMLReader.capability(extension); |
| const content = document.querySelector("#content"); |
| 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"); |
| const title = params.get("title") || "在线阅读"; |
| const ocrUrl = params.get("ocr") || ""; |
| 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 zoom = 1; |
| let currentPage = 1; |
| let pageCount = 0; |
| let restoredEntry = null; |
| let saveTimer = 0; |
| let pdfDocument = null; |
| let pdfRenderGeneration = 0; |
| let pdfActiveRenders = 0; |
| let pdfShellsReady = Promise.resolve(); |
| let restorationApplied = false; |
| const pdfRenderWaiters = []; |
| let epubRendition = null; |
| let epubBook = null; |
| let epubLocation = ""; |
| let epubProgress = 0; |
| let htmlFrame = null; |
| let lastSavedProgress = ""; |
| let progressSaveChain = Promise.resolve(); |
| let historySuppressed = false; |
| let restorationReady = false; |
| let restorationFailed = false; |
| let tocEntries = []; |
| let markerFrame = 0; |
| let pendingBookmarkSnapshot = null; |
| let showingAllBookmarks = false; |
| let bookmarkRenderGeneration = 0; |
| 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"); |
| const bookmarksAllButton = document.createElement("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 loadingObserver = new MutationObserver(() => { |
| if (content.querySelector(".reader-page, .reader-image, .reader-audio, .reader-video, .reader-text, .reader-markdown, .html-frame, .docx-body")) { |
| loadingIndicator.remove(); |
| loadingObserver.disconnect(); |
| } |
| }); |
| loadingObserver.observe(content, { childList: true }); |
| document.querySelector(".page-controls").hidden = capability.mode !== "pdf"; |
| document.querySelector(".zoom-controls").hidden = ["audio", "video"].includes(capability.mode); |
|
|
| document.querySelector("#title").textContent = title; |
| document.title = title + " - VoiceOfML Reader"; |
| 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"); |
| const readerPanelHeader = document.querySelector("#history-panel > header"); |
| let themeAnimationTimer = 0; |
| readerThemeToggle.className = "reader-theme-toggle icon-button"; |
| readerPanelHeader.insertBefore(readerThemeToggle, document.querySelector("#history-close")); |
| document.querySelector("#history-panel > footer").remove(); |
| function applyReaderTheme(theme, persist = true, animate = true) { |
| 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"); |
| if (epubRendition) epubRendition.themes.select(readerTheme === "dark" ? "reader-dark" : "reader-light"); |
| if (persist) localStorage.setItem("theme", readerTheme); |
| } |
| 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 (_) {} |
| } |
| try { |
| const folderTarget = new URL(folderReturnUrl, location.origin); |
| if (readerPathLabel && folderTarget.origin === location.origin && folderTarget.pathname.split("/").filter(Boolean).length === 1) { |
| readerPath.textContent = readerPathLabel; |
| readerPath.setAttribute("aria-label", `筛选文件夹:${readerPathLabel}`); |
| readerPath.hidden = false; |
| status.hidden = true; |
| readerPath.addEventListener("click", () => { |
| if (window.parent !== window) { |
| window.parent.postMessage({ type: "voice-reader-navigate", url: folderTarget.href }, location.origin); |
| return; |
| } |
| clearReturnNavigation(); |
| location.assign(folderTarget.href); |
| }); |
| } |
| } catch (_) {} |
| 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 > 1) { |
| history.back(); |
| } |
| else location.assign(target.href); |
| return; |
| } |
| } catch (_) {} |
| location.assign("/"); |
| }); |
| function setZoom(percent, persist = true) { |
| const normalized = VoiceOfMLReader.clampNumber(percent, 25, 400, 100); |
| const horizontalCenter = viewport.scrollWidth ? (viewport.scrollLeft + viewport.clientWidth / 2) / viewport.scrollWidth : 0; |
| zoom = normalized / 100; |
| content.style.setProperty("--reader-zoom", String(zoom)); |
| zoomInput.value = String(normalized); |
| if (epubRendition) epubRendition.themes.fontSize(`${normalized}%`); |
| if (htmlFrame && htmlFrame.contentDocument) htmlFrame.contentDocument.documentElement.style.zoom = String(zoom); |
| if (pdfDocument) rerenderVisiblePdfPages(); |
| viewport.scrollLeft = horizontalCenter * viewport.scrollWidth - viewport.clientWidth / 2; |
| if (persist) scheduleSave(); |
| } |
| 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(); } }); |
| document.querySelector("#page-prev").addEventListener("click", () => epubRendition ? epubRendition.prev() : goToPage(currentPage - 1)); |
| document.querySelector("#page-next").addEventListener("click", () => epubRendition ? epubRendition.next() : goToPage(currentPage + 1)); |
|
|
| async function goToPage(value) { |
| if (!pageCount) return; |
| const page = VoiceOfMLReader.clampNumber(value, 1, pageCount, 1); |
| await pdfShellsReady; |
| const shell = content.querySelector(`.reader-page[data-page="${page}"], .reader-docx-page[data-page="${page}"]`); |
| if (shell) { |
| if (shell.classList.contains("reader-page")) await renderPdfShell(shell, false, true); |
| shell.scrollIntoView({ block: "start" }); |
| if (!restorationApplied && restoredEntry && page === restoredEntry.page && restoredEntry.pageOffset) { viewport.scrollTop += restoredEntry.pageOffset; restorationApplied = true; } |
| } |
| currentPage = page; pageInput.value = String(page); scheduleSave(); |
| } |
| function scheduleSave() { clearTimeout(saveTimer); saveTimer = setTimeout(saveProgress, 500); } |
| async function saveProgress() { |
| if (!restorationReady || !validSource(sourceUrl) || historySuppressed) return; |
| syncCurrentPageFromMarker(); |
| const shell = pageCount ? content.querySelector(`.reader-page[data-page="${currentPage}"], .reader-docx-page[data-page="${currentPage}"]`) : null; |
| const pageOffset = shell ? Math.max(0, viewport.scrollTop - shell.offsetTop) : 0; |
| const htmlScrollTop = htmlFrame && htmlFrame.contentWindow ? htmlFrame.contentWindow.scrollY : 0; |
| const readerUrl = new URL(location.href); readerUrl.searchParams.delete("return"); readerUrl.searchParams.delete("nav"); |
| const progress = { url: sourceUrl, title, extension, readerUrl: readerUrl.href, page: currentPage, pageCount, pageOffset, epubLocation, scrollTop: viewport.scrollTop, htmlScrollTop, zoom: Math.round(zoom * 100) }; |
| const signature = JSON.stringify(progress); |
| if (signature === lastSavedProgress) return progressSaveChain; |
| lastSavedProgress = signature; |
| progressSaveChain = progressSaveChain.catch(() => {}).then(() => 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; |
| function setReaderPanelOpen(open, restoreFocus = false) { |
| const panel = document.querySelector("#history-panel"); |
| clearTimeout(panelAnimationTimer); |
| document.querySelector("#history").setAttribute("aria-expanded", String(open)); |
| if (open) { |
| panel.hidden = false; |
| void panel.offsetWidth; |
| panel.classList.add("is-open"); |
| selectPanel(tocEntries.length ? "toc" : "bookmarks"); |
| return; |
| } |
| panel.classList.remove("is-open"); |
| panelAnimationTimer = setTimeout(() => { if (!panel.classList.contains("is-open")) panel.hidden = true; }, 250); |
| if (restoreFocus) document.querySelector("#history").focus(); |
| } |
| 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 === "toc" && !tocEntries.length) name = "bookmarks"; |
| 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 === "bookmarks") renderBookmarks(); |
| if (name === "history") renderHistory(); |
| } |
| function setToc(entries) { |
| tocEntries = entries || []; |
| document.querySelector("#toc-tab").hidden = !tocEntries.length; |
| const list = document.querySelector("#toc-list"); list.textContent = ""; |
| for (const entry of tocEntries) { |
| const row = document.createElement("div"); row.className = "panel-item toc-item"; row.style.setProperty("--toc-depth", String(entry.depth || 0)); |
| const link = document.createElement("div"); link.className = "panel-item-main"; link.tabIndex = 0; link.setAttribute("role", "link"); link.textContent = entry.label; |
| const activate = async () => { if (getSelection().toString()) return; await entry.activate(); setReaderPanelOpen(false, true); }; |
| link.addEventListener("click", activate); link.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); activate(); } }); |
| row.appendChild(link); list.appendChild(row); |
| } |
| } |
| async function navigateReader(rawUrl) { |
| await saveProgress(); |
| if (window.parent !== window) window.parent.postMessage({ type: "voice-reader-open", url: rawUrl }, location.origin); |
| else location.assign(rawUrl); |
| } |
| async function renderHistory() { |
| const list = document.querySelector("#history-list"); list.textContent = ""; |
| try { |
| for (const entry of await VoiceOfMLReaderStore.list()) { |
| const row = document.createElement("div"); row.className = "panel-item"; |
| 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 () => { await VoiceOfMLReaderStore.remove(entry.url); if (entry.url === sourceUrl) historySuppressed = true; row.remove(); if (!list.querySelector(".panel-item")) emptyPanel(list, "暂无阅读记录"); }); |
| row.append(link, remove, meta); list.appendChild(row); |
| } |
| if (!list.childElementCount) emptyPanel(list, "暂无阅读记录"); |
| filterPanel(document.querySelector("#history-view")); |
| } catch (_) { emptyPanel(list, "无法读取本地记录"); } |
| } |
| function readerProgressPercent() { |
| if (epubLocation) { const location = epubRendition && epubRendition.currentLocation ? epubRendition.currentLocation() : null, percentage = location && location.start && Number.isFinite(location.start.percentage) ? location.start.percentage : epubProgress; return Math.round(Math.max(0, Math.min(1, percentage)) * 1000) / 10; } |
| 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"].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); |
| } |
| } |
| 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 text = content.textContent.replace(/\s+/g, " ").trim(), start = Math.floor(text.length * readerProgressPercent() / 100); |
| return text.slice(start, start + 160).trim(); |
| } |
| function bookmarkLocator() { syncCurrentPageFromMarker(); if (epubLocation) return `epub:${epubLocation}`; if (pageCount) return `page:${currentPage}`; const scrollTop = htmlFrame && htmlFrame.contentWindow ? htmlFrame.contentWindow.scrollY : viewport.scrollTop; return `progress:${readerProgressPercent()}:${Math.round(scrollTop)}`; } |
| function bookmarkLabel() { if (pageCount) return `第 ${currentPage} / ${pageCount} 页`; return `阅读进度 ${readerProgressPercent().toFixed(1)}%`; } |
| async function renderBookmarks() { |
| const list = document.querySelector("#bookmarks-list"), generation = ++bookmarkRenderGeneration; |
| try { |
| const entries = showingAllBookmarks ? await VoiceOfMLReaderStore.listAllBookmarks() : await VoiceOfMLReaderStore.listBookmarks(sourceUrl); |
| if (generation !== bookmarkRenderGeneration) return; |
| list.textContent = ""; |
| for (const entry of entries) { |
| const row = document.createElement("div"); row.className = "panel-item"; |
| const open = document.createElement("button"); open.type = "button"; open.className = "panel-item-main"; open.textContent = showingAllBookmarks ? `${entry.title || "未命名书籍"} · ${entry.label}` : entry.label; open.addEventListener("click", async () => { if (entry.url !== sourceUrl) { await navigateReader(entry.readerUrl); return; } if (entry.epubLocation && epubRendition) await epubRendition.display(entry.epubLocation); else if (entry.page) { await goToPage(entry.page); if (Number.isFinite(entry.pageOffset)) { const shell = content.querySelector(`.reader-page[data-page="${entry.page}"], .reader-docx-page[data-page="${entry.page}"]`); if (shell) viewport.scrollTop = shell.offsetTop + entry.pageOffset; } } else if (Number.isFinite(entry.htmlScrollTop) && htmlFrame && htmlFrame.contentWindow) htmlFrame.contentWindow.scrollTo(0, entry.htmlScrollTop); else viewport.scrollTop = entry.scrollTop || 0; }); |
| 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, "暂无书签"); }); |
| row.append(open, remove, excerpt, meta); list.appendChild(row); |
| } |
| if (!list.childElementCount) emptyPanel(list, "暂无书签"); |
| filterPanel(document.querySelector("#bookmarks-panel")); |
| } catch (_) { if (generation === bookmarkRenderGeneration) 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() { const page = pageAtMarker(); if (!page) return; const next = Number(page.dataset.page); if (!next || next === currentPage) return; currentPage = next; pageInput.value = String(next); } |
| function scheduleMarkerSync() { if (markerFrame) return; markerFrame = requestAnimationFrame(() => { markerFrame = 0; syncCurrentPageFromMarker(); scheduleSave(); }); } |
| function captureBookmarkSnapshot() { |
| syncCurrentPageFromMarker(); |
| const shell = 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 locator = epubLocation ? `epub:${epubLocation}` : pageCount ? `page:${currentPage}:${Math.round(pageOffset)}` : `progress:${progress}:${Math.round(htmlFrame ? htmlScrollTop : scrollTop)}`; |
| return { locator, label: pageCount ? `第 ${currentPage} / ${pageCount} 页` : `阅读进度 ${progress.toFixed(1)}%`, excerpt: bookmarkExcerpt(), progress, page: pageCount ? currentPage : 0, pageOffset, epubLocation, scrollTop, htmlScrollTop }; |
| } |
| function setBookmarkDialogModal(open) { document.documentElement.classList.toggle("bookmark-dialog-open", open); document.querySelector(".reader-toolbar").inert = open; bookmarkRibbon.inert = open; content.inert = open; } |
| function closeBookmarkPopover() { pendingBookmarkSnapshot = null; bookmarkPopover.hidden = true; bookmarkRibbon.setAttribute("aria-expanded", "false"); setBookmarkDialogModal(false); bookmarkRibbon.focus(); } |
| bookmarkRibbon.addEventListener("click", () => { const prompt = document.querySelector("#bookmark-prompt"); pendingBookmarkSnapshot = captureBookmarkSnapshot(); prompt.textContent = `在${pendingBookmarkSnapshot.label}添加书签?`; if (pendingBookmarkSnapshot.excerpt) { const summary = document.createElement("small"); summary.textContent = `摘要:${pendingBookmarkSnapshot.excerpt}`; prompt.appendChild(summary); } bookmarkPopover.hidden = false; bookmarkRibbon.setAttribute("aria-expanded", "true"); setBookmarkDialogModal(true); document.querySelector("#bookmark-add").focus(); }); |
| document.querySelector("#bookmark-cancel").addEventListener("click", closeBookmarkPopover); |
| document.querySelector("#bookmark-add").addEventListener("click", async () => { const snapshot = pendingBookmarkSnapshot || captureBookmarkSnapshot(), now = Date.now(); await VoiceOfMLReaderStore.putBookmark({ id: `${sourceUrl}\0${snapshot.locator}`, url: sourceUrl, title, extension, readerUrl: location.href, label: snapshot.label, excerpt: snapshot.excerpt, progress: snapshot.progress, page: snapshot.page, pageOffset: snapshot.pageOffset, epubLocation: snapshot.epubLocation, scrollTop: snapshot.scrollTop, htmlScrollTop: snapshot.htmlScrollTop, createdAt: now }); pendingBookmarkSnapshot = null; bookmarkPopover.hidden = true; bookmarkRibbon.setAttribute("aria-expanded", "false"); setBookmarkDialogModal(false); if (!document.querySelector("#bookmarks-panel").hidden) renderBookmarks(); bookmarkRibbon.focus(); }); |
| bookmarkPopover.addEventListener("keydown", (event) => { if (event.key === "Escape") { event.preventDefault(); closeBookmarkPopover(); return; } if (event.key !== "Tab") return; const buttons = [...bookmarkPopover.querySelectorAll("button")], first = buttons[0], last = buttons[buttons.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", () => { showingAllBookmarks = !showingAllBookmarks; bookmarksAllButton.textContent = showingAllBookmarks ? "本书书签" : "全部书签"; bookmarksAllButton.setAttribute("aria-pressed", String(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")) input.addEventListener("input", () => filterPanel(input.closest(".reader-panel-view"))); |
| document.querySelector("#history-clear").addEventListener("click", async () => { if (!confirm("清空全部阅读历史?")) return; await VoiceOfMLReaderStore.clearHistory(); historySuppressed = true; 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 && event.data.type === "voice-reader-theme-state") applyReaderTheme(event.data.theme, false); }); |
| 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", () => { |
| scheduleMarkerSync(); |
| }, { passive: true }); |
| window.addEventListener("pagehide", saveProgress); |
| document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") saveProgress(); }); |
|
|
| function validSource(raw) { |
| try { |
| const url = new URL(raw); |
| if (url.protocol !== "https:" || !["huggingface.co", "hf-mirror.com"].includes(url.hostname)) return false; |
| const readerAsset = /^\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[a-z0-9-]+\/)?(document\.pdf|book\.epub|document\.docx|document\.html|audio\.mp3|video\.mp4)$/.test(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 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 fail(message) { loadingStatus.hidden = true; loadingIndicator.remove(); content.innerHTML = `<div class="reader-error">${message}</div>`; status.textContent = "无法打开"; } |
|
|
| async function renderPdf(prepared) { |
| const pdf = await prepared; |
| pdfDocument = pdf; |
| pageCount = pdf.numPages; pageInput.max = String(pageCount); document.querySelector("#page-total").textContent = `/ ${pageCount}`; |
| status.textContent = `${pdf.numPages} 页`; |
| const firstPage = await pdf.getPage(1); |
| const firstViewport = firstPage.getViewport({ scale: 1 }); |
| const observer = new IntersectionObserver((entries) => entries.forEach((entry) => { |
| entry.target.dataset.renderVisible = entry.isIntersecting ? "1" : "0"; |
| if (entry.isIntersecting) renderPdfShell(entry.target); |
| }), { root: document.querySelector("#viewport"), rootMargin: "1200px 0px" }); |
| const pageObserver = new IntersectionObserver(() => scheduleMarkerSync(), { root: document.querySelector("#viewport"), threshold: [0, 0.5, 1] }); |
| 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", () => renderPdfShell(shell)); |
| observer.observe(shell); pageObserver.observe(shell); return shell; |
| }; |
| const firstShell = createShell(1); content.appendChild(firstShell); |
| await renderPdfShell(firstShell, false, true); |
| pdfShellsReady = (async () => { |
| for (let start = 2; start <= pdf.numPages; start += 24) { |
| const fragment = document.createDocumentFragment(); |
| for (let page = start; page < Math.min(start + 24, pdf.numPages + 1); page++) fragment.appendChild(createShell(page)); |
| content.appendChild(fragment); |
| await new Promise((resolve) => setTimeout(resolve, 0)); |
| } |
| })(); |
| await pdfShellsReady; |
| if (typeof pdf.getOutline === "function") { |
| try { |
| const outline = await pdf.getOutline(), entries = []; |
| const append = (items, depth = 0) => { for (const item of items || []) { entries.push({ label: item.title || "未命名章节", depth, activate: async () => { let destination = item.dest; if (typeof destination === "string") destination = await pdf.getDestination(destination); if (!destination || !destination[0]) return; const page = await pdf.getPageIndex(destination[0]) + 1; await goToPage(page); } }); append(item.items, depth + 1); } }; |
| append(outline); setToc(entries); |
| } catch (error) { console.warn("PDF outline could not be loaded", error); } |
| } |
| if (restoredEntry && restoredEntry.page) await goToPage(restoredEntry.page); |
| else syncCurrentPageFromMarker(); |
| } |
|
|
| async function renderPdfShell(shell, force = false, priority = false) { |
| if (!pdfDocument) return; |
| if (shell.dataset.renderState === "rendering") { if (force) shell.dataset.pendingRerender = "1"; return shell._renderPromise; } |
| if (!force && shell.dataset.renderState === "rendered") return; |
| let finishRender; |
| shell._renderPromise = new Promise((resolve) => { finishRender = resolve; }); |
| const generation = pdfRenderGeneration; |
| shell.dataset.renderState = "rendering"; |
| await acquirePdfRenderSlot(priority); |
| try { |
| const page = await pdfDocument.getPage(Number(shell.dataset.page)); |
| const base = page.getViewport({ scale: 1 }); |
| const scale = Math.min(3, Math.max(0.5, shell.clientWidth / base.width)); |
| const rendered = page.getViewport({ scale }); |
| const canvas = shell.querySelector("canvas"); |
| canvas.width = rendered.width; canvas.height = rendered.height; |
| shell.style.aspectRatio = `${rendered.width} / ${rendered.height}`; |
| await Promise.all([page.render({ canvasContext: canvas.getContext("2d"), viewport: rendered }).promise, renderPdfText(page, shell)]); |
| if (generation !== pdfRenderGeneration || shell.dataset.pendingRerender) { |
| shell.dataset.renderState = "idle"; |
| delete shell.dataset.pendingRerender; |
| setTimeout(() => renderPdfShell(shell, true, priority), 0); |
| return; |
| } |
| canvas.classList.add("ready"); shell.dataset.renderState = "rendered"; |
| shell.dataset.renderUsedAt = String(Date.now()); |
| trimPdfCanvases(); |
| } catch (error) { |
| shell.dataset.renderState = "idle"; |
| console.warn(`PDF page ${shell.dataset.page} render failed`, error); |
| const retries = Number(shell.dataset.renderRetries || 0); |
| if (priority) throw error; |
| if (retries < 3) { shell.dataset.renderRetries = String(retries + 1); setTimeout(() => renderPdfShell(shell, true), 400 * (retries + 1)); } |
| } finally { releasePdfRenderSlot(); finishRender(); delete shell._renderPromise; } |
| } |
|
|
| async function renderPdfText(page, shell) { |
| if (shell.dataset.textReady === "1") return; |
| const layer = shell.querySelector(".reader-pdf-text"); |
| if (!layer || typeof page.getTextContent !== "function") return; |
| try { |
| const text = await page.getTextContent(), 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 }; }); |
| layer.textContent = text.items.map((item) => item.str + (item.hasEOL ? "\n" : " ")).join("").trim() || "此页没有可提取文本"; |
| shell.dataset.textReady = "1"; |
| } catch (error) { console.warn(`PDF page ${shell.dataset.page} text extraction failed`, error); } |
| } |
|
|
| function acquirePdfRenderSlot(priority = false) { |
| const limit = matchMedia("(max-width: 700px)").matches ? 1 : 2; |
| if (pdfActiveRenders < limit) { pdfActiveRenders++; return Promise.resolve(); } |
| return new Promise((resolve) => { |
| const resume = () => { pdfActiveRenders++; resolve(); }; |
| if (priority) pdfRenderWaiters.unshift(resume); else pdfRenderWaiters.push(resume); |
| }); |
| } |
| function releasePdfRenderSlot() { |
| pdfActiveRenders = Math.max(0, pdfActiveRenders - 1); |
| const resume = pdfRenderWaiters.shift(); |
| if (resume) resume(); |
| } |
| function trimPdfCanvases() { |
| 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.dataset.renderVisible === "1" || Number(a.dataset.page) === currentPage; |
| const bVisible = b.dataset.renderVisible === "1" || Number(b.dataset.page) === currentPage; |
| if (aVisible !== bVisible) return aVisible ? 1 : -1; |
| const distance = Math.abs(Number(b.dataset.page) - currentPage) - Math.abs(Number(a.dataset.page) - currentPage); |
| return distance || Number(a.dataset.renderUsedAt || 0) - Number(b.dataset.renderUsedAt || 0); |
| }); |
| while (rendered.length > limit) { |
| const shell = rendered.shift(); |
| if (shell.dataset.renderVisible === "1" || Number(shell.dataset.page) === currentPage) continue; |
| const canvas = shell.querySelector("canvas"); |
| canvas.width = 0; canvas.height = 0; canvas.classList.remove("ready"); |
| shell.dataset.renderState = "idle"; |
| } |
| } |
|
|
| function rerenderVisiblePdfPages() { |
| pdfRenderGeneration++; |
| for (const shell of content.querySelectorAll(".reader-page")) { |
| const rect = shell.getBoundingClientRect(); |
| if (rect.bottom >= -1200 && rect.top <= innerHeight + 1200) { |
| renderPdfShell(shell, true); |
| } |
| } |
| } |
| async function renderText(markdown, prepared) { |
| let response = await prepared.response; |
| if (!response.ok) response = await fetch(sourceUrl); |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); |
| if (!markdown) await renderPlainText(response); |
| else { |
| const bytes = new Uint8Array(await response.arrayBuffer()); |
| const text = new TextDecoder(detectTextEncoding(bytes, 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); |
| const headings = [...article.querySelectorAll("h1,h2,h3,h4,h5,h6")].filter((heading) => heading.textContent.trim()); |
| setToc(headings.map((heading) => ({ label: heading.textContent.trim(), depth: Number(heading.tagName.slice(1)) - 1, activate: () => heading.scrollIntoView({ block: "start" }) }))); |
| } |
| status.textContent = "已加载"; |
| } |
| async function renderHtml(prepared) { |
| const response = await prepared.response; |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); |
| const bytes = new Uint8Array(await response.arrayBuffer()); |
| const text = new TextDecoder(detectHtmlEncoding(bytes, title)).decode(bytes); |
| const offlineText = text.replace(/@import[^;]+;|url\s*\([^)]*\)/gi, ""); |
| await prepared.engine; |
| const clean = DOMPurify.sanitize(offlineText, { |
| 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);/i, |
| }); |
| const frame = document.createElement("iframe"); htmlFrame = frame; |
| frame.className = "html-frame"; |
| frame.setAttribute("sandbox", "allow-same-origin"); |
| frame.setAttribute("referrerpolicy", "no-referrer"); |
| frame.style.colorScheme = "only light"; |
| const frameLoaded = new Promise((resolve) => frame.addEventListener("load", () => { repairHtmlContrast(frame); frame.contentDocument.documentElement.style.zoom = String(zoom); if (restoredEntry && Number.isFinite(restoredEntry.htmlScrollTop)) frame.contentWindow.scrollTo(0, restoredEntry.htmlScrollTop); frame.contentWindow.addEventListener("scroll", scheduleSave, { passive: true }); const headings = [...frame.contentDocument.querySelectorAll("h1,h2,h3,h4,h5,h6")].filter((heading) => heading.textContent.trim()); setToc(headings.map((heading) => ({ label: heading.textContent.trim(), depth: Number(heading.tagName.slice(1)) - 1, activate: () => heading.scrollIntoView({ block: "start" }) }))); resolve(); }, { once: true })); |
| frame.srcdoc = clean + '<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>'; |
| content.appendChild(frame); |
| await frameLoaded; |
| status.textContent = "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) { |
| const pre = document.createElement("pre"); pre.className = "reader-text"; |
| const textNode = document.createTextNode(""); pre.appendChild(textNode); content.appendChild(pre); |
| if (!response.body || !response.body.getReader) { |
| const bytes = new Uint8Array(await response.arrayBuffer()); |
| textNode.data = new TextDecoder(detectTextEncoding(bytes, title)).decode(bytes).replace(/\ufffd/g, ""); |
| return; |
| } |
| const reader = response.body.getReader(), chunks = [], asciiPreview = []; |
| let sampleSize = 0, displayedSampleSize = 0, streamDone = false, asciiPreviewPossible = true; |
| while (!streamDone && sampleSize < 65540) { |
| const { value, done } = await reader.read(); |
| streamDone = done; |
| if (value && value.length) { |
| chunks.push(value); sampleSize += value.length; |
| if (!displayedSampleSize && asciiPreviewPossible) { |
| for (const byte of value) { |
| if (!byte || byte >= 128) { asciiPreviewPossible = false; break; } |
| asciiPreview.push(byte); |
| } |
| if (asciiPreview.length >= 8) { textNode.appendData(new TextDecoder("utf-8").decode(new Uint8Array(asciiPreview))); displayedSampleSize = asciiPreview.length; } |
| } else if (displayedSampleSize === sampleSize - value.length) { |
| let asciiLength = 0; while (asciiLength < value.length && value[asciiLength] > 0 && value[asciiLength] < 128) asciiLength++; |
| if (asciiLength) { textNode.appendData(new TextDecoder("utf-8").decode(value.subarray(0, asciiLength))); displayedSampleSize += asciiLength; } |
| } |
| } |
| } |
| const sample = new Uint8Array(sampleSize); |
| let sampleOffset = 0; |
| for (const chunk of chunks) { sample.set(chunk, sampleOffset); sampleOffset += chunk.length; } |
| const decoder = new TextDecoder(detectTextEncoding(sample, title)); |
| let pending = "", frame = 0; |
| const flush = () => { frame = 0; if (pending) { textNode.appendData(pending); pending = ""; } }; |
| const scheduleFlush = () => { if (!frame) frame = requestAnimationFrame(flush); }; |
| pending = decoder.decode(sample.subarray(displayedSampleSize), { stream: !streamDone }).replace(/\ufffd/g, ""); |
| scheduleFlush(); |
| while (!streamDone) { |
| const { value, done } = await reader.read(); |
| if (done) { streamDone = true; break; } |
| pending += decoder.decode(value, { stream: true }).replace(/\ufffd/g, ""); |
| scheduleFlush(); |
| } |
| pending += decoder.decode().replace(/\ufffd/g, ""); |
| if (frame) cancelAnimationFrame(frame); |
| flush(); |
| } |
| 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 renderEpub(prepared) { |
| const [response] = await prepared; |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); |
| const bytes = await response.arrayBuffer(); |
| if (typeof JSZip.loadAsync === "function") { |
| try { |
| const archive = await JSZip.loadAsync(bytes), containerFile = archive.file("META-INF/container.xml"); |
| if (!containerFile) throw new Error("missing container"); |
| const containerXml = await containerFile.async("text"), match = containerXml.match(/full-path=["']([^"']+)["']/i); |
| if (!match || !archive.file(match[1])) throw new Error("missing package"); |
| } catch (_) { throw new Error("EPUB_INVALID"); } |
| } |
| const frame = document.createElement("div"); frame.className = "epub-frame"; content.appendChild(frame); |
| await displayEpub(bytes, frame); |
| loadingIndicator.remove(); loadingObserver.disconnect(); |
| status.textContent = "EPUB"; |
| } |
| async function displayEpub(url, frame) { |
| const book = ePub(url); epubBook = book; epubRendition = book.renderTo(frame, { width: "100%", height: "100%", manager: "continuous", spread: "none", flow: "scrolled-doc" }); |
| epubRendition.themes.register("reader-dark", { "html, body": { "color-scheme": "dark !important", "background": "#181b1e !important", "color": "#e7e9eb !important" }, "body, body *": { "border-color": "#4a5056 !important" }, "p, div, span, li, td, th, blockquote, pre, code, h1, h2, h3, h4, h5, h6": { "color": "inherit !important" }, "a, a *": { "color": "#8ab4e8 !important" }, "table, pre, code, blockquote": { "background-color": "#202428 !important" } }); |
| epubRendition.themes.register("reader-light", { "html, body": { "color-scheme": "light !important", "background": "#ffffff !important", "color": "#202124 !important" }, "a, a *": { "color": "#165ea8 !important" } }); |
| epubRendition.themes.select(readerTheme === "dark" ? "reader-dark" : "reader-light"); |
| epubRendition.themes.fontSize(`${Math.round(zoom * 100)}%`); |
| epubRendition.on("relocated", (location) => { epubLocation = location && location.start ? location.start.cfi : ""; epubProgress = location && location.start && Number.isFinite(location.start.percentage) ? location.start.percentage : epubProgress; scheduleSave(); }); |
| const restoredLocation = restoredEntry && restoredEntry.epubLocation; |
| try { await epubRendition.display(restoredLocation || undefined); } |
| catch (error) { if (!restoredLocation) throw error; epubLocation = ""; await epubRendition.display(); } |
| try { |
| const navigation = await book.loaded.navigation, entries = []; |
| const append = (items, depth = 0) => { for (const item of items || []) { entries.push({ label: item.label || item.title || "未命名章节", depth, activate: () => epubRendition.display(item.href) }); append(item.subitems, depth + 1); } }; |
| append(navigation && navigation.toc); setToc(entries); |
| } catch (error) { console.warn("EPUB navigation could not be loaded", error); } |
| } |
| async function renderDocx(prepared) { |
| const [response] = await prepared; |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); |
| const bytes = await response.arrayBuffer(); |
| 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) { |
| pageCount = pages.length; pageInput.max = String(pageCount); document.querySelector("#page-total").textContent = `/ ${pageCount}`; document.querySelector(".page-controls").hidden = false; |
| pages.forEach((page, index) => { page.classList.add("reader-docx-page"); page.dataset.page = String(index + 1); }); |
| const headings = [...body.querySelectorAll("h1,h2,h3,h4,h5,h6")].filter((heading) => heading.textContent.trim()); |
| setToc(headings.map((heading) => ({ label: heading.textContent.trim(), depth: Number(heading.tagName.slice(1)) - 1, activate: () => heading.scrollIntoView({ block: "start" }) }))); |
| if (restoredEntry && restoredEntry.page) await goToPage(restoredEntry.page); else syncCurrentPageFromMarker(); |
| } |
| 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"; } |
| } |
| status.textContent = pageCount ? `${pageCount} 页` : "DOCX"; |
| } |
| function renderMedia(mode) { |
| const media = document.createElement(mode); |
| media.className = mode === "audio" ? "reader-audio" : "reader-video"; |
| media.controls = true; |
| media.preload = "metadata"; |
| if (mode === "video") media.playsInline = true; |
| media.addEventListener("error", () => fail("媒体加载失败,请检查网络后重试,或下载原文件。"), { once: true }); |
| media.src = contentUrl; |
| content.appendChild(media); |
| status.textContent = mode === "audio" ? "音频" : "视频"; |
| } |
| async function start() { |
| if (!validSource(sourceUrl) || capability.readerMode === VoiceOfMLReader.ReaderMode.UNSUPPORTED) return fail("此文件暂不支持在线阅读,请下载原文件。"); |
| document.querySelector("#download").href = `/api/download?file=${encodeURIComponent(title)}&link=${encodeURIComponent(downloadUrl)}`; |
| if (validOcr(ocrUrl)) { const ocr = document.querySelector("#ocr"); ocr.href = ocrUrl; ocr.hidden = false; } |
| try { |
| let prepared; |
| [restoredEntry, prepared] = await Promise.all([ |
| VoiceOfMLReaderStore.get(sourceUrl).catch(() => { restorationFailed = true; return null; }), |
| prepareDocument(), |
| ]); |
| if (restoredEntry && restoredEntry.zoom) setZoom(restoredEntry.zoom, false); |
| if (capability.mode === "pdf") await renderPdf(prepared); |
| else if (capability.mode === "image") { content.appendChild(prepared); status.textContent = "图片"; } |
| else if (capability.mode === "text") await renderText(false, prepared); |
| else if (capability.mode === "markdown") await renderText(true, prepared); |
| else if (capability.mode === "html") await renderHtml(prepared); |
| else if (capability.mode === "epub") await renderEpub(prepared); |
| else if (capability.mode === "docx") await renderDocx(prepared); |
| else if (capability.mode === "audio" || capability.mode === "video") renderMedia(capability.mode); |
| loadingIndicator.remove(); |
| loadingStatus.hidden = true; |
| if (!pageCount && restoredEntry) viewport.scrollTop = restoredEntry.scrollTop || 0; |
| restorationReady = !restorationFailed; |
| scheduleSave(); |
| } catch (error) { console.error(error); fail(error && error.message === "EPUB_INVALID" ? "源 EPUB 文件不完整或已损坏,请下载原文件检查。" : "原文件加载失败,请检查网络后重试,或下载原文件。"); } |
| } |
| function prepareDocument() { |
| if (capability.mode === "pdf") return import(PDFJS_URL).then((pdfjs) => { pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL; const options = (url) => ({ url, wasmUrl: PDFJS_WASM_URL, cMapUrl: PDFJS_CMAP_URL, cMapPacked: true, standardFontDataUrl: PDFJS_STANDARD_FONT_URL, withCredentials: false }); return pdfjs.getDocument(options(contentUrl)).promise.catch(() => pdfjs.getDocument(options(sourceUrl)).promise); }); |
| if (capability.mode === "markdown") return Promise.all([fetch(contentUrl), Promise.all([loadScript(MARKED_URL), loadScript(PURIFY_URL)])]).then(([response, engines]) => ({ response, engines })); |
| if (capability.mode === "html") return Promise.all([fetch(contentUrl), loadScript(PURIFY_URL)]).then(([response, engine]) => ({ response, engine })); |
| if (capability.mode === "text") return fetch(contentUrl).then((response) => ({ response })); |
| if (capability.mode === "epub") return Promise.all([fetch(contentUrl), loadScript(JSZIP_URL).then(() => loadScript(EPUB_URL))]); |
| if (capability.mode === "docx") return Promise.all([fetch(contentUrl), loadScript(JSZIP_URL).then(() => loadScript(DOCX_PREVIEW_URL))]); |
| if (capability.mode === "audio" || capability.mode === "video") return Promise.resolve(null); |
| if (capability.mode === "image") return new Promise((resolve, reject) => { const image = new Image(); image.className = "reader-image"; image.alt = title; image.decoding = "async"; let fallback = false; image.onload = () => resolve(image); image.onerror = () => { if (!fallback) { fallback = true; image.src = sourceUrl; } else reject(new Error("image load failed")); }; image.src = contentUrl; }); |
| return Promise.resolve(null); |
| } |
| start(); |
|
|