diff --git a/TODO.md b/TODO.md index 1865a21..3a38e13 100644 --- a/TODO.md +++ b/TODO.md @@ -120,21 +120,21 @@ --- -### 6. 🔄 Комплексная переработка навигации, роутинга и честного прогресса чтения (In Progress) -- [ ] **6.1. Нативная маршрутизация окон (URL Hash Router & Zero-Flicker F5)**: +### 6. 🔄 Комплексная переработка навигации, роутинга и честного прогресса чтения (Completed) +- [x] **6.1. Нативная маршрутизация окон (URL Hash Router & Zero-Flicker F5)**: - Внедрение хэш-роутинга: `#dictionary`, `#srs`, `#bookshelf`, `#reader/:book_id`, `#reader/:book_id/ch/:chapter_idx`. - Синхронное переключение `view` при старте скрипта (до любых сетевых запросов авторизации): если в URL `#reader/...`, сразу скрывать `#dictionary-view` и показывать скелетон читалки, устраняя мигание словарем. - Развязка асинхронных вызовов: `loadBookshelf()` больше не должен принудительно гасить `readerInterface`, если уже инициировано открытие книги через `openBook()`. - Поддержка нативной истории браузера (кнопки «Назад» / «Вперёд» на мыши, клавиатуре и мобильных свайпах). - Автоматическая синхронизация активного экрана в URL и `localStorage`. -- [ ] **6.2. Честный непрерывный расчет прогресса книги и главы (Weighted Reading Progress)**: +- [x] **6.2. Честный непрерывный расчет прогресса книги и главы (Weighted Reading Progress)**: - Устранение бага ложных «100%»: полная ликвидация формулы `(current_ch + 1) / total_ch`, выдававшей 100% на первой странице одноглавных документов. - Прогресс текущей главы в реальном времени: непрерывный расчет от вертикального скролла (`scrollTop / (scrollHeight - clientHeight) * 100`) от 0% до 100%. - Взвешенный общий прогресс книги: расчет по объему слов прочитанных глав + точная доля текущей главы: $$\text{TotalProgress} = \frac{\sum_{i=0}^{\text{cur}-1} \text{words}(ch_i) + \text{words}(ch_{\text{cur}}) \times \frac{\text{ChapterPct}}{100}}{\text{TotalWords}} \times 100\%$$ - Для документа на 1000 слов прогресс будет плавно расти: 0% $\rightarrow$ 15% $\rightarrow$ 50% $\rightarrow$ 100%. - Отображение честного суммарного прогресса на карточках книг в библиотеке и в тулбаре читалки. -- [ ] **6.3. Интеллектуальный парсинг глав и оглавление (Smart Chapter & ToC Detection)**: +- [x] **6.3. Интеллектуальный парсинг глав и оглавление (Smart Chapter & ToC Detection)**: - Расширение детектора глав в `app/reader.py`: - Markdown-заголовки всех уровней (`# `, `## `, `### `, `#### `). - Нумерованные разделы и модули (`1. `, `1.1 `, `2.0 `, `Section 1`, `Part 1`, `Раздел 1`). @@ -142,7 +142,7 @@ - Жирные заголовки в начале абзацев (`**Chapter 1**`, `**Глава 1**`). - Адаптивный порог авто-разбивки: снижение порога чанкования текста без явных глав с 3000 до 800–1000 слов, чтобы даже небольшие статьи имели логическое деление на части. - Интерактивное оглавление со статусами прочтения: `✔ Прочитано`, `▶ В процессе (45%)`, `○ Не начато`. -- [ ] **6.4. Устойчивая фиксация позиции чтения (Paragraph Anchor + Scroll)**: +- [x] **6.4. Устойчивая фиксация позиции чтения (Paragraph Anchor + Scroll)**: - Сохранение не только пикселей `scrollTop`, но и индекса первого видимого абзаца (`anchor_paragraph_idx`). - Устойчивость к изменению шрифта `Aa` и ориентации экрана: восстановление скролла к конкретному предложению/абзацу, а не слепым пикселям. - Фиксация скролл-контейнера `#reader-scroll-area` с аппаратным ускорением на мобильных. diff --git a/app/reader.py b/app/reader.py index ff6930e..7f26db8 100644 --- a/app/reader.py +++ b/app/reader.py @@ -319,38 +319,180 @@ def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple if len(lines) > 1: content = "\n".join(lines[1:]).strip() - heading_pattern = re.compile( - r"^[ \t]*(?:" - r"#{1,3}[ \t]+(.+)|" - r"((?:Chapter|CHAPTER|Глава|ГЛАВА|Part|PART|Section|SECTION|Act|ACT|Scene|SCENE)[ \t]+[0-9IVXLCDMivxlcdm\w\.\-]+(?:[ \t]*[:\-–—\.][ \t]*.*|[ \t]+[^\n\r]+)?)" - r")[ \t]*$", - re.MULTILINE, +def detect_text_headings(content: str) -> List[Dict]: + """ + Detects section and chapter headings with high accuracy across Markdown, + numbered sections, explicit keywords, bold titles, and Setext styles. + """ + lines = content.split("\n") + line_starts = [] + curr = 0 + for l in lines: + line_starts.append(curr) + curr += len(l) + 1 + + kw_regex = re.compile( + r"^[ \t]*(?:\*\*)?(?:Chapter|CHAPTER|Глава|ГЛАВА|Part|PART|Часть|ЧАСТЬ|Section|SECTION|Раздел|РАЗДЕЛ|Module|MODULE|Модуль|МОДУЛЬ|Unit|UNIT|Lesson|LESSON|Урок|УРОК|Topic|TOPIC|Тема|ТЕМА|Appendix|APPENDIX|Приложение|ПРИЛОЖЕНИЕ|Act|ACT|Scene|SCENE)[ \t]+(?:[0-9IVXLCDMivxlcdm\w\.\-]+)(?:[ \t]*[:\-–—\.][ \t]*[^\n\r*]+|[ \t]+[^\n\r*]+)?(?:\*\*)?[ \t]*$" ) - matches = list(heading_pattern.finditer(content)) + headings = [] + + for i, line in enumerate(lines): + trimmed = line.strip() + if not trimmed: + continue + + prev_empty = (i == 0) or (not lines[i - 1].strip()) + next_empty = (i == len(lines) - 1) or (not lines[i + 1].strip()) + + # 1. Markdown # heading (# to #####) + m_md = re.match(r"^#{1,5}[ \t]+(.+)$", trimmed) + if m_md: + clean_title = m_md.group(1).strip("#").strip() + headings.append({ + "line_idx": i, + "start": line_starts[i], + "title": clean_title, + "type": "markdown", + }) + continue + + # 2. Setext heading: Title followed by === or --- + if i + 1 < len(lines): + next_line = lines[i + 1].strip() + if prev_empty and len(trimmed) <= 80 and not trimmed.startswith(("-", "*", "#", "`")): + if re.match(r"^={3,}$", next_line) or re.match(r"^-{3,}$", next_line): + headings.append({ + "line_idx": i, + "start": line_starts[i], + "title": trimmed, + "type": "setext", + }) + continue + + # 3. Explicit keywords (Chapter 1, Глава 2, Module 3, etc.) + if kw_regex.match(trimmed): + clean_title = trimmed.strip("*").strip() + headings.append({ + "line_idx": i, + "start": line_starts[i], + "title": clean_title, + "type": "keyword", + }) + continue + + # 4. Bold heading on standalone line: **Title** + bold_m = re.match(r"^\*\*([A-ZА-ЯЁ0-9][^\n\r*]{2,70})\*\*$", trimmed) + if bold_m and prev_empty: + headings.append({ + "line_idx": i, + "start": line_starts[i], + "title": bold_m.group(1).strip(), + "type": "bold", + }) + continue + + # 5. Numbered sections: e.g. 1. Title, 1.1 Title, 1. Title. + num_m = re.match(r"^([0-9]{1,2}(?:\.[0-9]{1,2})*\.?|[0-9]{1,2}\))[ \t]+([A-ZА-ЯЁ][^\n\r]{2,70})$", trimmed) + if num_m and prev_empty: + is_list = False + if i + 1 < len(lines): + next_t = lines[i + 1].strip() + if re.match(r"^[0-9]{1,2}(?:\.[0-9]{1,2})*\.?[ \t]+", next_t): + is_list = True + if not is_list and not trimmed.endswith((",", ";")): + headings.append({ + "line_idx": i, + "start": line_starts[i], + "title": trimmed, + "type": "numbered", + }) + continue + + # 6. Standalone ALL CAPS line (e.g. INTRODUCTION TO NETWORKS) + next_is_allcaps = (i + 1 < len(lines)) and bool(lines[i + 1].strip()) and lines[i + 1].strip().isupper() + if prev_empty and not next_is_allcaps and 4 <= len(trimmed) <= 60 and not trimmed.endswith((",", ";")): + if re.match(r"^[A-ZА-ЯЁ0-9\s:\-–—\.]+$", trimmed): + letters = re.findall(r"[A-ZА-ЯЁ]", trimmed) + words = trimmed.split() + if len(letters) >= 4 and (len(words) >= 2 or len(trimmed) >= 6): + headings.append({ + "line_idx": i, + "start": line_starts[i], + "title": trimmed, + "type": "allcaps", + }) + continue + + return headings + + +def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple[str, str, List[Dict[str, str]]]: + """ + Parses plain text or Markdown into readable chapters. + Splits by intelligent heading detection (Markdown, numbered sections, keywords) + or chunks of ~1000 words. + """ + content = content.replace("\r\n", "\n").replace("\r", "\n").strip() + if not content: + return title, author, [{"title": title if title != "Untitled" else "Chapter 1", "content": ""}] + + lines = content.split("\n") + first_line = lines[0].strip() + + # Extract title from first line or # Heading /