diff --git a/app/reader.py b/app/reader.py index 2c51b8a..3f8efaa 100644 --- a/app/reader.py +++ b/app/reader.py @@ -513,15 +513,17 @@ CLI_PROMPT_RE = re.compile( def extract_outline_entries(outline, reader) -> List[Tuple[str, int]]: """ - Recursively extracts bookmarks / outline entries from PDF. - Returns a list of (title, 0-indexed page_number). + Recursively extracts bookmarks / outline entries from PDF respecting hierarchy depth. + For books with deep trees (Parts -> Chapters -> Sections -> Subsections), + prioritizes major structural headings and prevents fragmenting chapters into + hundreds of tiny 1-page snippets. """ - entries: List[Tuple[str, int]] = [] + raw_entries: List[Tuple[str, int, int]] = [] - def _traverse(items): + def _traverse(items, depth=0): for item in items: if isinstance(item, list): - _traverse(item) + _traverse(item, depth + 1) else: try: title = getattr(item, "title", None) or (item.get("/Title") if hasattr(item, "get") else None) @@ -529,13 +531,44 @@ def extract_outline_entries(outline, reader) -> List[Tuple[str, int]]: if title and page_num is not None: clean_title = str(title).strip() if clean_title: - entries.append((clean_title, int(page_num))) + raw_entries.append((clean_title, int(page_num), depth)) except Exception: continue if outline: - _traverse(outline) - return entries + _traverse(outline, 0) + + if not raw_entries: + return [] + + # If total entries is reasonable (<= 50), return all + if len(raw_entries) <= 50: + return [(t, p) for t, p, _ in raw_entries] + + # Check depth distribution + d0_entries = [e for e in raw_entries if e[2] == 0] + d1_entries = [e for e in raw_entries if e[2] <= 1] + + # If top-level (depth 0) already has >= 10 chapters, use depth 0 + if len(d0_entries) >= 10: + return [(t, p) for t, p, _ in d0_entries] + + # Otherwise, if depth <= 1 gives a good number of chapters (10 to 80), use depth <= 1 + if len(d1_entries) <= 80: + return [(t, p) for t, p, _ in d1_entries] + + # If still too many entries (> 80), filter by major chapter keywords + major_re = re.compile( + r"^(cover|title\s+page|copyright|contents|introduction|your\s+study\s+plan|" + r"part\s+[0-9ivxlcdm]+|chapter\s+\d+|appendix\s+[a-z]|glossary|index)", + re.IGNORECASE + ) + major_entries = [e for e in d1_entries if major_re.match(e[0])] + if len(major_entries) >= 10: + return [(t, p) for t, p, _ in major_entries] + + return [(t, p) for t, p, _ in d1_entries[:80]] + def clean_page_lines(raw_pages_text: List[str]) -> List[str]: diff --git a/app/static/index.html b/app/static/index.html index 8755fad..e94a815 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -1075,7 +1075,7 @@ - + diff --git a/app/static/js/app.js b/app/static/js/app.js index d7d12fe..f63e62a 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -2515,7 +2515,7 @@ document.addEventListener('DOMContentLoaded', () => { if (!currentBook || !currentBook.chapters) return; currentChapterIndex = Math.max(0, Math.min(targetChapter, currentBook.chapters.length - 1)); - readerChapterSelect.value = currentChapterIndex; + if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex; readerPrevChBtn.disabled = currentChapterIndex <= 0; readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1; @@ -2528,15 +2528,13 @@ document.addEventListener('DOMContentLoaded', () => { if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c); }); - currentBook.chapters.forEach((_, idx) => { - loadedChaptersSet.add(idx); - const chFrag = buildChapterDOM(idx, knownWordsMap, idx > 0); - readerContentBody.appendChild(chFrag); - }); + loadedChaptersSet.add(currentChapterIndex); + const chFrag = buildChapterDOM(currentChapterIndex, knownWordsMap, false); + readerContentBody.appendChild(chFrag); - const finalFooter = buildChapterFooterDOM(currentBook.chapters.length - 1); - if (finalFooter) { - readerContentBody.appendChild(finalFooter); + const footer = buildChapterFooterDOM(currentChapterIndex); + if (footer) { + readerContentBody.appendChild(footer); } applyScroll(restoreAnchorIdx, restoreScrollTop, restoreFraction, currentChapterIndex); @@ -2871,9 +2869,9 @@ document.addEventListener('DOMContentLoaded', () => { processed = processed.replace(/(?$1'); processed = processed.replace(/(?$1'); - // 3. Parse into DOM tree - const parser = new DOMParser(); - const doc = parser.parseFromString(`${processed}`, 'text/html'); + // 3. Parse into DOM tree via native template fragment + const tpl = document.createElement('template'); + tpl.innerHTML = processed; // 4. Recursive transformer: keeps formatting elements, makes text nodes interactive words function transformDOMNode(node) { @@ -2888,6 +2886,7 @@ document.addEventListener('DOMContentLoaded', () => { const span = document.createElement('span'); span.className = 'reader-word'; span.dataset.word = token; + span.dataset.context = contextText || raw; span.textContent = token; const lower = token.toLowerCase(); @@ -2899,11 +2898,6 @@ document.addEventListener('DOMContentLoaded', () => { span.classList.add('highlight-enabled'); } - span.addEventListener('click', (e) => { - e.stopPropagation(); - handleWordClick(token, contextText || raw, span); - }); - frag.appendChild(span); } else { frag.appendChild(document.createTextNode(token)); @@ -2993,7 +2987,7 @@ document.addEventListener('DOMContentLoaded', () => { return document.createTextNode(''); } - Array.from(doc.body.childNodes).forEach(childNode => { + Array.from(tpl.content.childNodes).forEach(childNode => { container.appendChild(transformDOMNode(childNode)); }); } @@ -3453,13 +3447,6 @@ document.addEventListener('DOMContentLoaded', () => { if (activeCh !== null && activeCh !== undefined && activeCh !== currentChapterIndex) { currentChapterIndex = activeCh; if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex; - if (readerChapterSelect && readerChapterSelect.options) { - for (let i = 0; i < readerChapterSelect.options.length; i++) { - const rawTitle = currentBook.chapters[i]?.title || `Глава ${i + 1}`; - const icon = i < currentChapterIndex ? '✔ ' : (i === currentChapterIndex ? '▶ ' : '○ '); - readerChapterSelect.options[i].textContent = `${icon}${rawTitle}`; - } - } if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0; if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1; @@ -3470,16 +3457,16 @@ document.addEventListener('DOMContentLoaded', () => { } } - // 2. Infinite scroll: auto-append next chapter in chapters mode - if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext && !isAppendingChapter) { + // 2. Infinite scroll: auto-append next chapter in continuous mode or chapters mode with autoload + if ((readerSettings.flowMode === 'continuous' || (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext)) && !isAppendingChapter) { const distanceToBottom = readerScrollArea.scrollHeight - (readerScrollArea.scrollTop + readerScrollArea.clientHeight); - if (distanceToBottom < 450) { + if (distanceToBottom < 700) { const loadedArr = Array.from(loadedChaptersSet); const highestCh = loadedArr.length > 0 ? Math.max(...loadedArr) : currentChapterIndex; if (currentBook && currentBook.chapters && highestCh < currentBook.chapters.length - 1) { isAppendingChapter = true; appendNextChapter(highestCh + 1, false); - setTimeout(() => { isAppendingChapter = false; }, 300); + setTimeout(() => { isAppendingChapter = false; }, 200); } } } @@ -3832,6 +3819,18 @@ document.addEventListener('DOMContentLoaded', () => { initReaderSettingsPanel(); + if (readerContentBody) { + readerContentBody.addEventListener('click', (e) => { + const wordSpan = e.target.closest('.reader-word'); + if (wordSpan) { + e.stopPropagation(); + const word = wordSpan.dataset.word; + const context = wordSpan.dataset.context || wordSpan.closest('p, div, li, td, th, blockquote')?.textContent || word; + handleWordClick(word, context, wordSpan); + } + }); + } + if (readerToggleHighlightBtn) { readerToggleHighlightBtn.addEventListener('click', () => { isHighlightEnabled = !isHighlightEnabled;