diff --git a/AGENTS.md b/AGENTS.md index 908368966f..65cf991442 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,41 +30,42 @@ pnpm run test:tts-wake-cycle ## Current Task -Production Readiness Action Plan Implementation (2026-01-03) - ✅ COMPLETED +TTS Text Cleanup Pipeline (2026-08-02) - ✅ COMPLETED -### Phase 1: Critical Security & Bug Fixes (P0) - ✅ COMPLETED - -- **1.1**: Fixed cookie value truncation in WebviewScreen.tsx (handles values with `=`) -- **1.2**: Fixed Set-Cookie header parsing (handles comma + newline separation, equals in values) -- **1.3**: Replaced harsh `System.exit(0)` with graceful shutdown in DoHManagerModule.kt -- **1.4**: Added 5-second timeouts to DoH bootstrap client (connectTimeout, readTimeout, writeTimeout) -- **1.5**: Changed SharedPreferences from `apply()` to `commit()` (synchronous writes to prevent data loss) -- **1.6**: Removed obsolete `.pnpm-patches/cookies/` directory (kept patches/ directory) - -### Phase 2: Security Hardening (P1) - ✅ COMPLETED - -- **2.1**: ~~Added certificate pinning for DoH providers~~ → **REMOVED** (2026-01-11) - - Certificate pinning removed per OWASP 2025 guidance - - Reason: Third-party DoH providers rotate certs unpredictably, causing app outages - - Android's platform trust store + Certificate Transparency provides sufficient security - - See: OWASP Pinning Cheat Sheet - "don't pin if you don't control both sides" -- **2.2**: User confirmation dialog for app restart already implemented -- **2.3**: Cookie attribute filtering already implemented - -### Phase 4: Performance Optimization (P2) - ✅ COMPLETED - -- **4.1**: Increased TTS chapter list debounce from 500ms → 2000ms (4x reduction in DB queries) -- **4.2**: Code already optimized (single refresh pattern, no major duplication) -- **4.3**: Added React.memo with custom equality to ChapterItem (prevents re-renders on non-progress changes) - -### Phase 5: Testing & Documentation (P2) - ✅ COMPLETED - -- **5.1**: Cookie parsing tests comprehensive (special chars, URL encoding, multiple cookies, etc.) -- **5.2**: Updated AGENTS.md with completed task status -- **Tests**: All 1072 tests passing (zero regressions) +- **Feature**: Declarative, length-preserving text cleanup applied to every paragraph before it reaches the native TTS engine across ALL playback paths (initial queue, WebView tts-queue refills, fallback single-speak) +- **Capabilities**: Ordered find/replace + regex strip rules, phonetic pronunciation dictionary (whole-word/substring match), optional Unicode normalization, per-novel overrides +- **UI**: Global in Settings → Reader → Accessibility Tab; Quick access in Reader Bottom Sheet → TTS Tab ("Text Cleanup" section) +- **Commits**: 2d35beff0 (pipeline), 1a1ffb00d (regex safety), aeec7abb6 (per-novel overrides), b0b56f333 (substring mode + reorder), b47f527c0 (wiring tests), e4a78d623 (listener deps) — all 2026-08-02, branch `dev` (not yet pushed) +- **Tests**: 1235 passing (zero regressions) +- **Docs**: PRD at specs/tts-text-cleanup/PRD.md ### Previous Completed Tasks +- Production Readiness Action Plan Implementation (2026-01-03) - ✅ COMPLETED + - **Phase 1: Critical Security & Bug Fixes (P0)** - ✅ COMPLETED + - **1.1**: Fixed cookie value truncation in WebviewScreen.tsx (handles values with `=`) + - **1.2**: Fixed Set-Cookie header parsing (handles comma + newline separation, equals in values) + - **1.3**: Replaced harsh `System.exit(0)` with graceful shutdown in DoHManagerModule.kt + - **1.4**: Added 5-second timeouts to DoH bootstrap client (connectTimeout, readTimeout, writeTimeout) + - **1.5**: Changed SharedPreferences from `apply()` to `commit()` (synchronous writes to prevent data loss) + - **1.6**: Removed obsolete `.pnpm-patches/cookies/` directory (kept patches/ directory) + - **Phase 2: Security Hardening (P1)** - ✅ COMPLETED + - **2.1**: ~~Added certificate pinning for DoH providers~~ → **REMOVED** (2026-01-11) + - Certificate pinning removed per OWASP 2025 guidance + - Reason: Third-party DoH providers rotate certs unpredictably, causing app outages + - Android's platform trust store + Certificate Transparency provides sufficient security + - See: OWASP Pinning Cheat Sheet - "don't pin if you don't control both sides" + - **2.2**: User confirmation dialog for app restart already implemented + - **2.3**: Cookie attribute filtering already implemented + - **Phase 4: Performance Optimization (P2)** - ✅ COMPLETED + - **4.1**: Increased TTS chapter list debounce from 500ms → 2000ms (4x reduction in DB queries) + - **4.2**: Code already optimized (single refresh pattern, no major duplication) + - **4.3**: Added React.memo with custom equality to ChapterItem (prevents re-renders on non-progress changes) + - **Phase 5: Testing & Documentation (P2)** - ✅ COMPLETED + - **5.1**: Cookie parsing tests comprehensive (special chars, URL encoding, multiple cookies, etc.) + - **5.2**: Updated AGENTS.md with completed task status + - **Tests**: All 1072 tests passing (zero regressions) + - TTS Chapter List Progress Sync - Real-Time Fix (2026-01-03) - ✅ COMPLETED - **Bug**: Chapter List showed stale progress during active TTS playback - **Solution**: Added debounced `refreshChaptersFromContext()` call during paragraph progress saves (500ms debounce) @@ -113,6 +114,18 @@ Production Readiness Action Plan Implementation (2026-01-03) - ✅ COMPLETED ## Recent Fixes +### TTS Text Cleanup Pipeline (2026-08-02) - ✅ COMPLETED + +- **Feature**: Declarative, length-preserving text cleanup applied to every paragraph before it reaches the native TTS engine across ALL playback paths (initial queue, WebView tts-queue refills, fallback single-speak) +- **Capabilities**: Ordered find/replace + regex strip rules (literal or regex), phonetic pronunciation dictionary (whole-word/substring match), optional Unicode normalization (NFD + strip combining marks), per-novel overrides +- **Design Constraints**: No eval/Function in the RN layer; length-preserving (RN ↔ WebView paragraph index contract stays intact); no hardcoded site-specific regexes +- **Regex Safety**: 200-char length cap, catastrophic-backtracking shape detection, compile-time try/catch, literal replacement semantics (`$&` stays literal), sticky `y` flag dropped +- **Effective Settings**: `resolveEffectiveTtsCleanup()` → per-novel override when per-novel TTS enabled AND cleanup saved, else global; synced into `chapterGeneralSettingsRef` via `syncEffectiveTtsCleanup` (prop effect + MMKV listener + per-novel effect) +- **Files**: htmlParagraphExtractor.ts (pipeline, +350 lines), TtsTextCleanupModal.tsx (new, 685 lines), useSettings.ts, novelTtsSettings.ts, useTTSController.ts, useTTSUtilities.ts, WebViewReader.tsx, AccessibilityTab.tsx, ReaderTTSTab.tsx +- **Commits**: 2d35beff0 (pipeline), 1a1ffb00d (regex safety), aeec7abb6 (per-novel overrides), b0b56f333 (substring mode + reorder), b47f527c0 (wiring tests), e4a78d623 (listener deps) +- **Tests**: 1235 passing (zero regressions) +- **Docs**: PRD at specs/tts-text-cleanup/PRD.md + ### TTS highlight offset page reload and button state desync fix (2026-05-26) - ✅ COMPLETED - **Bug #1 - WebView reload on offset adjustment**: ✅ COMPLETED - Adjusting highlight offset does not reload reader @@ -300,7 +313,9 @@ React Native Layer ├── TTSAudioManager.ts Native module wrapper ├── TTSState.ts State machine definition ├── ttsBridge.ts RN↔WebView bridge -└── ttsNotification.ts Media notification utils +├── ttsNotification.ts Media notification utils +├── htmlParagraphExtractor.ts Paragraph extraction + text cleanup pipeline +└── novelTtsSettings.ts Per-novel TTS settings + cleanup overrides WebView Layer └── core.js DOM parsing, highlighting, scroll @@ -316,7 +331,8 @@ UI Components ├── TTSManualModeDialog.tsx Manual mode activation ├── TTSScrollSyncDialog.tsx Position mismatch ├── TTSChapterSelectionDialog.tsx Chapter picker -└── TTSExitDialog.tsx Exit confirmation +├── TTSExitDialog.tsx Exit confirmation +└── TtsTextCleanupModal.tsx Text cleanup rules & phonetic editor ``` ## Path Aliases diff --git a/README.md b/README.md index 3cfb0d3c92..0ceefeafe1 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ This fork builds on the original LNReader with enhanced features focused on acce - [TTS Feature Demo](#tts-feature-demo) - [Key TTS Features Showcase](#key-tts-features-showcase) - [Enhanced TTS Media Notification (Android)](#enhanced-tts-media-notification-android) + - [TTS Text Cleanup](#tts-text-cleanup) - [Reader Experience](#reader-experience) - [Network \& Security](#network--security) - [UI \& Accessibility](#ui--accessibility) @@ -72,6 +73,7 @@ This fork builds on the original LNReader with enhanced features focused on acce - [Getting Started](#getting-started) - [First-Time Setup](#first-time-setup) - [Using TTS](#using-tts) + - [Using TTS Text Cleanup](#using-tts-text-cleanup) - [Using Continuous Scrolling](#using-continuous-scrolling) - [Backup \& Restore](#backup--restore) - [Architecture](#architecture) @@ -116,6 +118,7 @@ This fork includes extensive TTS enhancements for hands-free reading and accessi | 🔧 **TTS Engine Picker** | Select system or custom TTS engines with quality badges and persistent selection | | 🏷️ **Auto Chapter Title Prepend** | Auto-announces chapter title via TTS when not visibly present in content | | 🖱️ **Advanced Button Gestures** | Tap to toggle playback, hold 0.5s + swipe to adjust highlight offset, hold 2s + drag to move | +| 🧹 **TTS Text Cleanup** | Strip watermarks/corrupted text & fix pronunciations before TTS reads — declarative rules, one-tap presets, per-novel overrides, JSON import/export | @@ -189,6 +192,40 @@ Android devices can have multiple TTS engines installed. The default engine is o --- +#### TTS Text Cleanup + +Sites like Novelight inject anti-scraper watermarks (spaced letters, unicode lookalikes, `u2014` corruption, "Do not rehost this novel" spam) that system TTS engines read aloud, and LN names/honorifics are commonly mispronounced. **Text Cleanup** fixes both with a declarative, **length-preserving** pipeline applied to every paragraph before it reaches the TTS engine — across all playback modes, including background playback. + +
+ +```mermaid +flowchart LR + %% Styles + classDef src fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#0d47a1,font-weight:bold + classDef clean fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#e65100,font-weight:bold + classDef out fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1b5e20,font-weight:bold + + %% Nodes + PARA["📄 Every Paragraph
(any playback path)"]:::src + NORM["🔤 1. Unicode Normalization
(optional: NFD + strip combining marks)"]:::clean + RULES["🧹 2. Find & Replace Rules
(literal or regex, ordered)"]:::clean + PHON["🗣️ 3. Phonetic Dictionary
(whole-word or substring)"]:::clean + TTS["🔈 Native TTS Engine"]:::out + + %% Flow + PARA ==> NORM ==> RULES ==> PHON ==> TTS +``` + +
+ +- **Access**: Settings → Reader → Accessibility → **TTS Text Cleanup** (global), or Reader Bottom Sheet → TTS Tab → **Text Cleanup** (quick access, per-novel aware) +- **Presets (one-tap)**: Curated templates — Novelight spaced watermark, `u2014` corruption, "(Official version)" tags, "Do not rehost" spam, math-bold lookalikes, LN name pronunciations, and CJK substring pairs. Presets are UI data only; applying copies them into your editable rules +- **Import / Export**: Share or restore your rule set as a versioned JSON envelope (`lnreader-tts-cleanup` v1) +- **Per-novel overrides**: With per-novel TTS settings enabled, cleanup can be overridden per novel +- **Safety**: Regex length cap + ReDoS-shape detection + invalid-regex skip; length-preserving (paragraph count never changes, so highlight/scroll stay in sync) + +--- + ### Reader Experience Enhanced features for smoother, more immersive reading. @@ -278,6 +315,9 @@ Robust backup system with multiple options and versioned schema. ### TTS Enhancements +- **TTS Text Cleanup**: Declarative rule pipeline strips anti-scraper watermarks, corrupted text, and fixes LN name pronunciations before TTS reads (Settings → Reader → Accessibility → TTS Text Cleanup; quick access in the Reader TTS tab) +- **Cleanup Presets**: One-tap curated templates (Novelight watermark, `u2014` corruption, LN/CJK pronunciations, and more) — UI data only, copied into your editable rules +- **Cleanup Import/Export**: Share or restore cleanup rule sets as versioned JSON from the editor - **TTS Engine Picker**: Custom engine selection with native Android integration, quality badges, and persistent selection across sessions - **Per-Novel TTS Settings**: Isolated voice/speed/pitch per novel — changes no longer overwrite global defaults - **TTS Resume Reliability**: Fixed resume playback failure and wrong engine audio output after interruptions @@ -325,6 +365,16 @@ View full changelog: [RELEASE_NOTES.md](RELEASE_NOTES.md) - **Hold 0.5s (Amber Glow) + Swipe Up/Down**: Adjust paragraph highlight offset (resets per chapter). - **Hold 2.0s (Teal Glow) + Drag**: Move the TTS button anywhere on the screen. +### Using TTS Text Cleanup + +1. **Enable**: Settings → Reader → Accessibility → TTS Text Cleanup → toggle "Clean TTS text" ON (or open Reader → TTS Tab → Text Cleanup) +2. **One-tap presets**: Tap **Add** on a preset (e.g. "Fix `u2014` text corruption") to copy curated rules into your list — deduplicated, fully editable +3. **Custom rules**: Add find/replace rules (literal or regex) to strip watermarks or corrupted text +4. **Phonetic dictionary**: Add name pronunciations (e.g. `Xianxia` → "Shee-an-shah"); enable **substring** mode for unspaced CJK names +5. **Unicode normalization**: Optionally NFD-normalize + strip combining marks to fix lookalike characters +6. **Import / Export**: From the editor, share your rule set as JSON (Export) or restore one (Import — replaces the draft, Cancel discards) +7. **Verify**: Play a chapter in foreground and background — cleaned text is spoken and paragraph highlighting stays in sync + ### Using Continuous Scrolling 1. Navigate to Settings → Reader @@ -363,6 +413,7 @@ The TTS engine uses a **Hybrid 3-Layer Architecture** to ensure reliable playbac - **Proactive Queue Refill**: Monitors queue size and refills (batch size ~20) before depletion to prevent audio gaps. - **State Reconciliation**: On load, syncs progress from three sources: Database (permanent), MMKV (fast), and Native (current utterance). - **Smart Wake-Up**: Detects app foregrounding and seamlessly syncs the visual reader position with the background audio position. +- **Declarative Text Cleanup**: Settings-driven find/replace + phonetic rules applied length-preserving at every audio entry point (initial queue, WebView DOM refills, fallback single-speak) — no arbitrary JS, no hardcoded site regexes.
diff --git a/specs/tts-text-cleanup/PRD.md b/specs/tts-text-cleanup/PRD.md new file mode 100644 index 0000000000..dcaa897611 --- /dev/null +++ b/specs/tts-text-cleanup/PRD.md @@ -0,0 +1,215 @@ +# Product Requirements Document: TTS Text Cleanup Pipeline + +**Status**: ✅ COMPLETED +**Feature Branch**: `dev` +**Date**: 2026-08-02 +**Session Utilization**: 100% + +--- + +## 1. What We Want to Do Now + +**Immediate Next Steps:** + +1. **Ship the declarative text cleanup pipeline** (completed ✅) - TTS currently reads watermarks, corrupted/scrambled text (e.g. `u2014`), and mispronounced light-novel names/honorifics (issue #17) + - Literal find/replace + regex strip rules (user-configurable, ordered) + - Phonetic pronunciation dictionary (whole-word or substring mode) + - Optional Unicode normalization (NFD + strip combining marks) + - Per-novel overrides on top of global settings + +2. **Harden regex safety** (completed ✅) + - Length cap + catastrophic-backtracking shape detection + compile-time try/catch + - Literal replacement semantics (`$&` stays literal, no `eval`/`Function` in the RN layer) + +3. **Documentation** (completed ✅) + - Update AGENTS.md with feature documentation (this session) + - PRD at `specs/tts-text-cleanup/PRD.md` (this file) + +--- + +## 2. Research Findings + +### Problem Statement + +- **Watermarks**: Many sources embed author/translator watermarks in chapter HTML (e.g. "Read on N o v e l i g h t", "Do not rehost this novel"). These are read aloud by TTS. +- **Corrupted text**: Character corruption / lookalike replacement characters (e.g. literal `u2014`, mathematical-bold letters) break pronunciation. +- **Mispronunciation**: LN-specific names and honorifics (e.g. "Xianxia", "Qing", CJK names) are read phonetically wrong by system TTS engines. +- **Manual workaround existed but was fragile**: hardcoded site-specific regexes scattered in the WebView layer — not configurable, not maintainable, and did not cover all playback paths. + +### Constraints (from code comments in `src/utils/htmlParagraphExtractor.ts`) + +- **No arbitrary user JS evaluation** in the RN/Hermes layer (no `eval`/`Function`). +- **Length-preserving**: cleanup never drops or merges array entries, so the RN ↔ WebView paragraph index contract stays intact (paragraph count drives indexing). +- **No hardcoded site-specific regexes** — everything is user-configurable. + +--- + +## 3. Implementation Plan + +### Phase 1: Core pipeline (Completed ✅ - commit `2d35beff0`) + +| Task | Status | Commit | +| --------------------------------------- | ------ | ---------- | +| 1.1 Declarative cleanup pipeline in utils | ✅ | 2d35beff0 | + +**Implementation Details:** + +**Files Modified:** + +- `src/utils/htmlParagraphExtractor.ts` + - Added cleanup API alongside the existing paragraph extractor: + - `TtsCleanupRule` (id, enabled, pattern, isRegex, flags, replacement) + - `TtsPhoneticPair` (id, enabled, word, pronunciation, matchMode) + - `TtsTextCleanupSettings` (enabled, normalizeUnicode, rules, phoneticPairs) + - `DEFAULT_TTS_CLEANUP_SETTINGS` + - `TTS_CLEANUP_MAX_REGEX_LENGTH` (200) + - `isPotentiallyCatastrophic()`, `normalizeRegExpFlags()`, `normalizeUnicodeText()` + - `createTtsCleanupRule()`, `createTtsPhoneticPair()` + - `cleanTtsText()` (single string) and `applyTtsTextCleanup()` (paragraph array, length-preserving) + - Pipeline order: **Unicode normalization → ordered rules → phonetic dictionary** + - Whole-word phonetic matching uses Unicode-aware boundaries `(^|[^\p{L}\p{N}_])word(?![...])` with a bounded regex cache (500 entries) + +### Phase 2: Regex safety hardening (Completed ✅ - commit `1a1ffb00d`) + +| Task | Status | Commit | +| --------------------------------------- | ------ | ---------- | +| 2.1 Regex safety + replacement semantics | ✅ | 1a1ffb00d | + +- **Length cap**: patterns > 200 chars are skipped. +- **Catastrophic-backtracking detection**: narrow structural heuristics reject shapes like `(a+)+`, `(?:a*)*`, `(?:a+){2,}`, `(a|a)+`. False positives are avoided (a rejected rule silently stops cleaning), false negatives acceptable (defense-in-depth on top of length cap + try/catch). +- **Compile-time try/catch**: invalid regex source leaves text untouched rather than crashing TTS. +- **Literal replacement**: regex replacements use the callback form so `$&`, `$'`, `` $` ``, `$$`, `$n` stay literal. +- **Flag normalization**: only valid flags kept, deduped, `g` always added, sticky `y` dropped (sticky without global scan silently no-ops on mid-string matches). + +### Phase 3: Per-novel overrides (Completed ✅ - commit `aeec7abb6`) + +| Task | Status | Commit | +| --------------------------------- | ------ | ---------- | +| 3.1 Per-novel cleanup overrides | ✅ | aeec7abb6 | + +- `src/services/tts/novelTtsSettings.ts`: `NovelTtsSettings` gains optional `ttsTextCleanup`; new `resolveEffectiveTtsCleanup(globalCleanup, novelId)` resolves per-novel override when per-novel mode is enabled AND a cleanup override was saved, else falls back to global. MMKV read failures degrade to global. +- `src/screens/reader/components/WebViewReader.tsx`: `novelIdRef` mirrors `novel.id` so mount-once MMKV listeners can resolve per-novel cleanup; `syncEffectiveTtsCleanup()` re-resolves the effective cleanup into `chapterGeneralSettingsRef` after every wholesale ref assignment (prop effect, MMKV listener, per-novel effect). + +### Phase 4: Substring mode + rule reordering (Completed ✅ - commit `b0b56f333`) + +| Task | Status | Commit | +| --------------------------------------- | ------ | ---------- | +| 4.1 Substring match mode + reorder UI | ✅ | b0b56f333 | + +- `TtsPhoneticPair.matchMode` (`'whole-word'` default | `'substring'`): whole-word boundaries never fire between adjacent CJK characters (both sides are `\p{L}`), so substring mode replaces every occurrence via split/join. Legacy persisted pairs without the field default to whole-word. +- Rule reorder UI: move up/down arrows for both find/replace rules and phonetic pairs (disabled at boundaries). + +### Phase 5: Controller wiring tests (Completed ✅ - commit `b47f527c0`) + +| Task | Status | Commit | +| --------------------------------- | ------ | ---------- | +| 5.1 Assert cleanup wiring in tests | ✅ | b47f527c0 | + +- `useTTSController.integration.test.ts`: asserts `applyTtsTextCleanup` is called with queue texts + effective settings before `addToBatch`; new test asserts `cleanTtsText` is called on the `'speak'` fallback path. +- `mediaNav`/`progressSync` suites do not dispatch speak/tts-queue messages (documented as covered elsewhere). + +### Phase 6: Dependency hygiene (Completed ✅ - commit `e4a78d623`) + +| Task | Status | Commit | +| -------------------------------------- | ------ | ---------- | +| 6.1 Stable sync callback in listener deps | ✅ | e4a78d623 | + +- `WebViewReader.tsx`: `syncEffectiveTtsCleanup` is a stable `useCallback` ([] deps), listed explicitly in the mount-once MMKV listener dependency array (suppresses an exhaustive-deps warning; safe because it never changes). + +--- + +## 4. Architecture + +### Layers + +1. **RN utilities layer**: `src/utils/htmlParagraphExtractor.ts` — declarative, pure, length-preserving cleanup pipeline (`cleanTtsText` / `applyTtsTextCleanup`). No side effects, no runtime user code execution. +2. **Controller wiring layer**: `useTTSController.ts`, `useTTSUtilities.ts`, `WebViewReader.tsx` — applies cleanup to every paragraph reaching the native TTS engine across ALL playback paths: + - Initial queue (all modes): RN `extractParagraphs()` output + - Foreground refill (Path B): WebView DOM `tts-queue` payloads + - Fallback single-speak: WebView `'speak'` payloads +3. **Settings layer**: global `ttsTextCleanup` on `ChapterGeneralSettings` (MMKV) + optional per-novel override in `NovelTtsSettings`. +4. **UI layer**: `TtsTextCleanupModal.tsx` with master switch, Unicode normalization toggle, ordered find/replace rules editor, and phonetic dictionary editor — reachable from both the Accessibility Tab (global) and Reader TTS Tab (global or per-novel). + +### Effective settings resolution + +``` +global ttsTextCleanup (ChapterGeneralSettings, MMKV) + │ + ▼ +resolveEffectiveTtsCleanup(global, novelId) + │ per-novel TTS enabled AND per-novel cleanup saved? + ├─ yes → per-novel ttsTextCleanup (replaces global) + └─ no → global ttsTextCleanup + │ + ▼ +chapterGeneralSettingsRef.current.ttsTextCleanup + │ (synced via syncEffectiveTtsCleanup on: chapterGeneralSettings change, + │ per-novel settings change, MMKV CHAPTER_GENERAL_SETTINGS listener) + ▼ +cleanTtsText / applyTtsTextCleanup at every playback path +``` + +--- + +## 5. UI / UX + +### TTS Text Cleanup Modal (`TtsTextCleanupModal.tsx`) + +- **Master switch**: "Clean TTS text" — applied to every paragraph before it reaches the TTS engine. +- **Unicode normalization toggle**: NFD-normalize + strip combining marks (e.g. `cafe\u0301` → `cafe`). +- **Find & Replace Rules** (ordered): literal or regex (with flags input), empty replacement = strip. Per-rule validation blocks invalid regex, over-length patterns, and suspected catastrophic patterns at save time. Rules reorderable via arrow icons. +- **Phonetic Dictionary** (ordered): word → pronunciation swaps; whole-word (default) or substring match mode toggle (needed for unspaced CJK). Pairs reorderable via arrow icons. + +### Entry Points + +- **Global**: More → Settings → Reader → Accessibility Tab → "TTS Text Cleanup" section → "Cleanup rules & phonetic dictionary" +- **Quick Access**: Reader Bottom Sheet → TTS Tab → "Text Cleanup" section → "Cleanup rules & phonetic dictionary" (saves to global when per-novel mode is off, per-novel when per-novel mode is on) + +--- + +## 6. Test Summary + +- `src/utils/__tests__/ttsTextCleanup.test.ts` (344 lines, new): cleanTtsText behavior, pipeline order, regex safety hardening (length cap, catastrophic patterns, literal `$&`, sticky-`y`), Unicode normalization, whole-word vs substring phonetic matching, applyTtsTextCleanup length-preservation guarantees. +- `src/services/__tests__/NovelTtsSettings.test.ts` (+84 lines): `resolveEffectiveTtsCleanup` resolution matrix (no novelId, no per-novel settings, per-novel disabled, per-novel enabled + saved, enabled but no override, MMKV read failure). +- `src/screens/reader/hooks/__tests__/useTTSController.integration.test.ts` (+56 lines): cleanup applied on `tts-queue` and `speak` message paths. +- Full suite: **1235 tests passing**, `tsc --noEmit` clean. + +--- + +## 7. File Map + +| File | Role | +| ---- | ---- | +| `src/utils/htmlParagraphExtractor.ts` | Cleanup pipeline + types + defaults (+350 lines) | +| `src/utils/__tests__/ttsTextCleanup.test.ts` | Cleanup unit tests (new, 344 lines) | +| `src/hooks/persisted/useSettings.ts` | Global `ttsTextCleanup` on `ChapterGeneralSettings` | +| `src/services/tts/novelTtsSettings.ts` | Per-novel override + `resolveEffectiveTtsCleanup` | +| `src/screens/reader/hooks/useTTSController.ts` | Cleanup wiring across all playback paths | +| `src/screens/reader/hooks/useTTSUtilities.ts` | Cleanup in utilities/restart path | +| `src/screens/reader/components/WebViewReader.tsx` | `novelIdRef`, `syncEffectiveTtsCleanup`, MMKV listener | +| `src/screens/settings/SettingsReaderScreen/Modals/TtsTextCleanupModal.tsx` | Settings UI (new, 685 lines) | +| `src/screens/settings/SettingsReaderScreen/tabs/AccessibilityTab.tsx` | Global settings entry point | +| `src/screens/reader/components/ReaderBottomSheet/ReaderTTSTab.tsx` | Quick-access entry point (global/per-novel) | + +## 8. Commits + +- `2d35beff0` feat(tts): add declarative text cleanup pipeline for TTS audio +- `1a1ffb00d` fix(tts): harden text cleanup regex safety and replacement semantics +- `aeec7abb6` feat(tts): support per-novel text cleanup overrides +- `b0b56f333` feat(tts): add substring match mode and rule reordering to cleanup editor +- `b47f527c0` test(tts): assert cleanup wiring in controller integration tests +- `e4a78d623` chore(tts): include stable sync callback in MMKV listener deps + +All on branch `dev` (not yet pushed at time of writing). + +## 9. Known Gotchas + +- **Unicode normalization + regex patterns**: when `normalizeUnicode` is enabled, text is NFD-normalized FIRST; regex patterns are matched verbatim against the already-normalized text. A regex containing precomposed characters (e.g. `é`) will NOT match the normalized form (`e` + combining accent). Literal (non-regex) patterns are NFD-normalized to match. This is by design — document it for users. +- **Partial-object robustness**: the runtime (`cleanTtsText`) guards `settings.rules ?? []` / `settings.phoneticPairs ?? []` so partial legacy objects degrade safely. Settings written by this feature always include both arrays. +- **Length preservation is contractual**: never drop or merge paragraphs in cleanup — the RN ↔ WebView paragraph index contract depends on it. + +--- + +**Last Updated**: 2026-08-02 +**Session Utilization**: 100% +**Completion**: 6/6 commits (100%) diff --git a/src/hooks/persisted/useSettings.ts b/src/hooks/persisted/useSettings.ts index 117462b6a2..13e46a43c8 100644 --- a/src/hooks/persisted/useSettings.ts +++ b/src/hooks/persisted/useSettings.ts @@ -7,6 +7,10 @@ import { useMMKVObject } from 'react-native-mmkv'; import { Voice } from 'expo-speech'; import { clampUIScale } from '@theme/scaling'; import { DoHProvider } from '@services/network/DoHManager'; +import { + TtsTextCleanupSettings, + DEFAULT_TTS_CLEANUP_SETTINGS, +} from '@utils/htmlParagraphExtractor'; export const APP_SETTINGS = 'APP_SETTINGS'; export const BROWSE_SETTINGS = 'BROWSE_SETTINGS'; @@ -243,6 +247,15 @@ export interface ChapterGeneralSettings { * Show discoverability hint toast for TTS floating button gestures */ ttsShowGestureHints: boolean; + /** + * TTS text cleanup pipeline applied to every paragraph before it reaches + * the native TTS engine. Includes ordered find/replace rules, a phonetic + * pronunciation dictionary, and optional Unicode normalization. + * Applied across ALL playback modes and paths (initial queue, WebView + * refills, fallback single-speak). Length-preserving: never drops or merges + * paragraphs, so the RN <-> WebView paragraph index contract stays intact. + */ + ttsTextCleanup: TtsTextCleanupSettings; } export interface ReaderTheme { @@ -381,6 +394,7 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = { continuousScrollTransitionThreshold: 15, continuousScrollStitchThreshold: 90, ttsShowGestureHints: true, + ttsTextCleanup: DEFAULT_TTS_CLEANUP_SETTINGS, }; export const initialChapterReaderSettings: ChapterReaderSettings = { diff --git a/src/screens/reader/components/ReaderBottomSheet/ReaderTTSTab.tsx b/src/screens/reader/components/ReaderBottomSheet/ReaderTTSTab.tsx index fa7eb2c5fe..f5a17da3a0 100644 --- a/src/screens/reader/components/ReaderBottomSheet/ReaderTTSTab.tsx +++ b/src/screens/reader/components/ReaderBottomSheet/ReaderTTSTab.tsx @@ -16,6 +16,7 @@ import { useBoolean } from '@hooks'; import { Portal } from 'react-native-paper'; import VoicePickerModal from '@screens/settings/SettingsReaderScreen/Modals/VoicePickerModal'; import EnginePickerModal from '@screens/settings/SettingsReaderScreen/Modals/EnginePickerModal'; +import TtsTextCleanupModal from '@screens/settings/SettingsReaderScreen/Modals/TtsTextCleanupModal'; import TTSScrollBehaviorModal from '@screens/settings/SettingsReaderScreen/Modals/TTSScrollBehaviorModal'; import Switch from '@components/Switch/Switch'; import { useChapterContext } from '../../ChapterContext'; @@ -26,6 +27,10 @@ import { } from '@services/tts/novelTtsSettings'; import { NovelInfo } from '@database/types'; import { createRateLimitedLogger } from '@utils/rateLimitedLogger'; +import { + DEFAULT_TTS_CLEANUP_SETTINGS, + type TtsTextCleanupSettings, +} from '@utils/htmlParagraphExtractor'; const readerTTSTabLog = createRateLimitedLogger('ReaderTTSTab', { windowMs: 1500, @@ -62,6 +67,7 @@ const ReaderTTSTab: React.FC = React.memo( ttsAutoStopMode = 'off', ttsAutoStopAmount = 0, ttsShowGestureHints = true, + ttsTextCleanup = DEFAULT_TTS_CLEANUP_SETTINGS, setChapterGeneralSettings, } = useChapterGeneralSettings(); @@ -194,6 +200,9 @@ const ReaderTTSTab: React.FC = React.memo( const [novelTtsOverride, setNovelTtsOverride] = useState | null>(null); + // Per-novel TTS text cleanup override (only meaningful in per-novel mode) + const [novelCleanupOverride, setNovelCleanupOverride] = + useState(null); // Compute effective TTS: per-novel overrides overlaid on clean global defaults const effectiveTts = useMemo(() => { if (useNovelTtsSettings && novelTtsOverride) { @@ -201,6 +210,14 @@ const ReaderTTSTab: React.FC = React.memo( } return tts ?? { rate: 1, pitch: 1 }; }, [tts, useNovelTtsSettings, novelTtsOverride]); + // Effective cleanup: per-novel override when saved, else global. + const effectiveCleanup = useMemo( + () => + useNovelTtsSettings && novelCleanupOverride + ? novelCleanupOverride + : ttsTextCleanup, + [useNovelTtsSettings, novelCleanupOverride, ttsTextCleanup], + ); const [voices, setVoices] = useState([]); @@ -231,6 +248,7 @@ const ReaderTTSTab: React.FC = React.memo( } else { setNovelTtsOverride(null); } + setNovelCleanupOverride(stored?.ttsTextCleanup ?? null); }, [novelId, debugLog]); const persistNovelTtsEnabled = useCallback( @@ -248,6 +266,7 @@ const ReaderTTSTab: React.FC = React.memo( setNovelTtsSettings(novelId, { enabled, tts: previous?.tts ?? effectiveTts, + ttsTextCleanup: previous?.ttsTextCleanup, }); debugLog('after persistNovelTtsEnabled write', { @@ -269,6 +288,7 @@ const ReaderTTSTab: React.FC = React.memo( setNovelTtsSettings(novelId, { enabled: true, tts: merged, + ttsTextCleanup: previous?.ttsTextCleanup, }); setNovelTtsOverride(merged); } else { @@ -359,6 +379,7 @@ const ReaderTTSTab: React.FC = React.memo( setNovelTtsSettings(novelId, { enabled: true, tts: newTts, + ttsTextCleanup: current?.ttsTextCleanup, }); setNovelTtsOverride(newTts); debugLog('engine-saved-per-novel', { @@ -409,6 +430,11 @@ const ReaderTTSTab: React.FC = React.memo( setTrue: showTtsAutoStopAmountModal, setFalse: hideTtsAutoStopAmountModal, } = useBoolean(); + const { + value: ttsTextCleanupModalVisible, + setTrue: showTtsTextCleanupModal, + setFalse: hideTtsTextCleanupModal, + } = useBoolean(); useEffect(() => { TTSHighlight.getVoices().then(res => { @@ -533,6 +559,8 @@ const ReaderTTSTab: React.FC = React.memo( setNovelTtsSettings(novelId, { enabled: true, tts: effectiveTts, + ttsTextCleanup: + getNovelTtsSettings(novelId)?.ttsTextCleanup, }); debugLog('saved baseline tts on enable', { @@ -838,6 +866,21 @@ const ReaderTTSTab: React.FC = React.memo( + {/* TTS Text Cleanup Settings */} + + Text Cleanup + r.enabled).length} active rules · ${(effectiveCleanup.phoneticPairs ?? []).filter(p => p.enabled).length} phonetic${useNovelTtsSettings && novelCleanupOverride ? ' · per-novel' : ''}` + : 'Disabled' + } + onPress={showTtsTextCleanupModal} + theme={theme} + /> + + {/* Auto-Download Settings */} Auto-Download @@ -1016,6 +1059,24 @@ const ReaderTTSTab: React.FC = React.memo( ] } /> + { + if (useNovelTtsSettings && typeof novelId === 'number') { + const previous = getNovelTtsSettings(novelId); + setNovelTtsSettings(novelId, { + enabled: true, + tts: previous?.tts ?? effectiveTts, + ttsTextCleanup: nextSettings, + }); + setNovelCleanupOverride(nextSettings); + } else { + setChapterGeneralSettings({ ttsTextCleanup: nextSettings }); + } + }} + /> ); diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index 70a2a1cfec..15b0999d34 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -65,11 +65,17 @@ import TTSChapterSelectionDialog from './TTSChapterSelectionDialog'; import TTSSyncDialog from './TTSSyncDialog'; import Toast from '@components/Toast'; import { useBoolean, useBackHandler } from '@hooks'; -import { extractParagraphs } from '@utils/htmlParagraphExtractor'; +import { + extractParagraphs, + applyTtsTextCleanup, + DEFAULT_TTS_CLEANUP_SETTINGS, + type TtsTextCleanupSettings, +} from '@utils/htmlParagraphExtractor'; import { applyTtsUpdateToWebView, type TTSSettings } from './ttsHelpers'; import TTSExitDialog from './TTSExitDialog'; import { getNovelTtsSettings, + resolveEffectiveTtsCleanup, useNovelTtsSettings, } from '@services/tts/novelTtsSettings'; import { createRateLimitedLogger } from '@utils/rateLimitedLogger'; @@ -214,6 +220,26 @@ const WebViewReaderRefactored: React.FC = ({ onPress }) => { const readerSettingsRef = useRef(readerSettings); const chapterGeneralSettingsRef = useRef(chapterGeneralSettings); + // Mirror novel.id in a ref so mount-once listeners (e.g. the MMKV + // CHAPTER_GENERAL_SETTINGS listener) can resolve per-novel cleanup even + // though the reader screen is mounted per-novel. + const novelIdRef = useRef(novel?.id); + useEffect(() => { + novelIdRef.current = novel?.id; + }, [novel?.id]); + + // Re-resolve the effective per-novel TTS text cleanup into the settings + // ref. Every wholesale ref assignment below is followed by this call, so + // the value converges to (per-novel override when saved, else global). + const syncEffectiveTtsCleanup = useCallback( + (globalCleanup?: TtsTextCleanupSettings | null) => { + chapterGeneralSettingsRef.current.ttsTextCleanup = + resolveEffectiveTtsCleanup(globalCleanup, novelIdRef.current) ?? + DEFAULT_TTS_CLEANUP_SETTINGS; + }, + [], + ); + // Apply per-novel TTS overrides (if enabled) on chapter/novel changes. // Updates ref + WebView — does NOT write to global ChapterReaderSettings. useEffect(() => { @@ -272,7 +298,8 @@ const WebViewReaderRefactored: React.FC = ({ onPress }) => { useEffect(() => { readerSettingsRef.current = readerSettings; chapterGeneralSettingsRef.current = chapterGeneralSettings; - }, [readerSettings, chapterGeneralSettings]); + syncEffectiveTtsCleanup(chapterGeneralSettings.ttsTextCleanup); + }, [readerSettings, chapterGeneralSettings, syncEffectiveTtsCleanup]); // Calculate initial saved paragraph index - MMKV is single source of truth const initialSavedParagraphIndex = useMemo(() => { @@ -357,6 +384,17 @@ const WebViewReaderRefactored: React.FC = ({ onPress }) => { const { tts: globalTts } = useChapterReaderSettings(); const [novelTtsSettings] = useNovelTtsSettings(novel?.id); + // Re-resolve effective cleanup when the per-novel override or the global + // settings change (the MMKV listener + ref-sync effect cover other paths). + useEffect(() => { + syncEffectiveTtsCleanup(chapterGeneralSettings.ttsTextCleanup); + }, [ + chapterGeneralSettings.ttsTextCleanup, + novel?.id, + novelTtsSettings, + syncEffectiveTtsCleanup, + ]); + const liveReaderTts = novel?.id && novelTtsSettings?.enabled && novelTtsSettings?.tts ? novelTtsSettings.tts @@ -400,7 +438,10 @@ const WebViewReaderRefactored: React.FC = ({ onPress }) => { TTSHighlight.stop(); const idx = tts.currentParagraphIndex; - const paragraphs = extractParagraphs(html, chapter.name); + const paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapter.name), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); if (paragraphs && paragraphs.length > idx) { tts.restartTtsFromParagraphIndex(idx); @@ -457,6 +498,7 @@ const WebViewReaderRefactored: React.FC = ({ onPress }) => { } chapterGeneralSettingsRef.current = merged; + syncEffectiveTtsCleanup(merged.ttsTextCleanup); readerLog.debug( 'mmkv-general-settings-ref-updated', @@ -538,7 +580,7 @@ const WebViewReaderRefactored: React.FC = ({ onPress }) => { subscription.remove(); mmkvListener.remove(); }; - }, [webViewRef, showToastMessage]); + }, [webViewRef, showToastMessage, syncEffectiveTtsCleanup]); // ============================================================================ // HTML Generation diff --git a/src/screens/reader/components/__tests__/WebViewReader.eventHandlers.test.tsx b/src/screens/reader/components/__tests__/WebViewReader.eventHandlers.test.tsx index de9513d70b..fd6190ace6 100644 --- a/src/screens/reader/components/__tests__/WebViewReader.eventHandlers.test.tsx +++ b/src/screens/reader/components/__tests__/WebViewReader.eventHandlers.test.tsx @@ -133,6 +133,8 @@ jest.mock('@utils/htmlParagraphExtractor', () => ({ 'Paragraph 4', 'Paragraph 5', ]), + applyTtsTextCleanup: jest.fn((paragraphs: string[]) => paragraphs), + cleanTtsText: jest.fn((text: string) => text), })); jest.mock('../ttsHelpers', () => ({ @@ -151,12 +153,27 @@ jest.mock('@database/queries/ChapterQueries', () => ({ })); // Mock novel-specific TTS settings to prevent interference -jest.mock('@services/tts/novelTtsSettings', () => ({ - getNovelTtsSettings: jest.fn(() => null), // Return null = no per-novel overrides - useNovelTtsSettings: jest.fn(() => [null]), - setNovelTtsSettings: jest.fn(), - deleteNovelTtsSettings: jest.fn(), -})); +jest.mock('@services/tts/novelTtsSettings', () => { + const getNovelTtsSettings = jest.fn((novelId?: number) => null) as jest.Mock; // Return null = no per-novel overrides + return { + getNovelTtsSettings, + useNovelTtsSettings: jest.fn(() => [null]), + setNovelTtsSettings: jest.fn(), + deleteNovelTtsSettings: jest.fn(), + resolveEffectiveTtsCleanup: jest.fn( + (globalCleanup: unknown, novelId?: number) => { + // Mirrors the real resolver against the mocked getNovelTtsSettings + // so WebViewReader's ref sync stays testable end-to-end. + const stored = + novelId !== undefined ? getNovelTtsSettings(novelId) : null; + if (stored?.enabled && stored.ttsTextCleanup) { + return stored.ttsTextCleanup; + } + return globalCleanup; + }, + ), + }; +}); // 3. Mock TTS Service & Dialogs jest.mock('@services/TTSHighlight', () => ({ diff --git a/src/screens/reader/components/__tests__/WebViewReader.integration.test.tsx b/src/screens/reader/components/__tests__/WebViewReader.integration.test.tsx index 3848efe184..d385848ae8 100644 --- a/src/screens/reader/components/__tests__/WebViewReader.integration.test.tsx +++ b/src/screens/reader/components/__tests__/WebViewReader.integration.test.tsx @@ -113,6 +113,8 @@ jest.mock('@hooks', () => ({ jest.mock('@utils/htmlParagraphExtractor', () => ({ extractParagraphs: jest.fn(() => ['P1', 'P2', 'P3', 'P4', 'P5']), + applyTtsTextCleanup: jest.fn((paragraphs: string[]) => paragraphs), + cleanTtsText: jest.fn((text: string) => text), })); jest.mock('../ttsHelpers', () => ({ @@ -149,10 +151,27 @@ jest.mock('@utils/ScreenStateListener', () => ({ addListener: jest.fn(() => ({ remove: jest.fn() })), })); -jest.mock('@services/tts/novelTtsSettings', () => ({ - getNovelTtsSettings: jest.fn(() => null), - useNovelTtsSettings: jest.fn(() => [null]), -})); +jest.mock('@services/tts/novelTtsSettings', () => { + const getNovelTtsSettings = jest.fn((novelId?: number) => null) as jest.Mock; + return { + getNovelTtsSettings, + useNovelTtsSettings: jest.fn(() => [null]), + setNovelTtsSettings: jest.fn(), + deleteNovelTtsSettings: jest.fn(), + resolveEffectiveTtsCleanup: jest.fn( + (globalCleanup: unknown, novelId?: number) => { + // Mirrors the real resolver against the mocked getNovelTtsSettings + // so WebViewReader's ref sync stays testable end-to-end. + const stored = + novelId !== undefined ? getNovelTtsSettings(novelId) : null; + if (stored?.enabled && stored.ttsTextCleanup) { + return stored.ttsTextCleanup; + } + return globalCleanup; + }, + ), + }; +}); const mockChapter = { id: 10, name: 'Chapter 10', progress: 0 }; jest.mock('../../ChapterContext', () => ({ diff --git a/src/screens/reader/hooks/__tests__/useTTSController.integration.test.ts b/src/screens/reader/hooks/__tests__/useTTSController.integration.test.ts index 34c6ad0d0b..241b7c09f1 100644 --- a/src/screens/reader/hooks/__tests__/useTTSController.integration.test.ts +++ b/src/screens/reader/hooks/__tests__/useTTSController.integration.test.ts @@ -52,6 +52,10 @@ import { markChapterRead, } from '@database/queries/ChapterQueries'; import { ChapterInfo, NovelInfo } from '@database/types'; +import { + applyTtsTextCleanup, + cleanTtsText, +} from '@utils/htmlParagraphExtractor'; import { ChapterGeneralSettings, ChapterReaderSettings, @@ -87,6 +91,8 @@ jest.mock('@utils/htmlParagraphExtractor', () => ({ 'Fourth paragraph', 'Fifth paragraph', ]), + applyTtsTextCleanup: jest.fn((paragraphs: string[]) => paragraphs), + cleanTtsText: jest.fn((text: string) => text), })); jest.mock('../../components/ttsHelpers', () => ({ validateAndClampParagraphIndex: jest.fn(index => Math.max(0, index)), @@ -2053,10 +2059,17 @@ describe('useTTSController - Integration Tests', () => { describe('WebView Message Routing', () => { it('should handle tts-queue message and initialize TTS', async () => { + const cleanupSettings = { + enabled: true, + normalizeUnicode: false, + rules: [], + phoneticPairs: [], + }; const params = createDefaultParams({ chapterGeneralSettingsRef: { current: { ttsBackgroundPlayback: true, // Enable background playback to trigger addToBatch + ttsTextCleanup: cleanupSettings, } as ChapterGeneralSettings, }, }); @@ -2064,10 +2077,11 @@ describe('useTTSController - Integration Tests', () => { jest.advanceTimersByTime(300); // Wait for isWebViewSyncedRef + const queueTexts = ['First paragraph', 'Second paragraph']; await act(async () => { const queueMessage: any = { type: 'tts-queue', - data: ['First paragraph', 'Second paragraph'], + data: queueTexts, chapterId: 100, startIndex: 0, }; @@ -2076,6 +2090,46 @@ describe('useTTSController - Integration Tests', () => { // Should call addToBatch (not speakBatch) when background playback is enabled expect(TTSHighlight.addToBatch).toHaveBeenCalled(); + // Cleanup must be applied to the DOM-fed refill paragraphs BEFORE + // they reach the native TTS engine, with the effective settings. + expect(applyTtsTextCleanup).toHaveBeenCalledWith( + queueTexts, + cleanupSettings, + ); + }); + + it('should apply text cleanup to speak message text', async () => { + const cleanupSettings = { + enabled: true, + normalizeUnicode: false, + rules: [], + phoneticPairs: [], + }; + const params = createDefaultParams({ + chapterGeneralSettingsRef: { + current: { + ttsBackgroundPlayback: true, + ttsTextCleanup: cleanupSettings, + } as ChapterGeneralSettings, + }, + }); + const { result } = renderHook(() => useTTSController(params)); + + await act(async () => { + const speakMessage: any = { + type: 'speak', + data: 'First paragraph text', + paragraphIndex: 0, + }; + result.current.handleTTSMessage(speakMessage as any); + }); + + // The fallback single-speak path must clean the DOM text before + // TTSHighlight.speak is reached. + expect(cleanTtsText).toHaveBeenCalledWith( + 'First paragraph text', + cleanupSettings, + ); }); it('should handle change-paragraph-position message', async () => { diff --git a/src/screens/reader/hooks/__tests__/useTTSController.mediaNav.test.ts b/src/screens/reader/hooks/__tests__/useTTSController.mediaNav.test.ts index 652e6388e8..c5820ad167 100644 --- a/src/screens/reader/hooks/__tests__/useTTSController.mediaNav.test.ts +++ b/src/screens/reader/hooks/__tests__/useTTSController.mediaNav.test.ts @@ -113,6 +113,8 @@ jest.mock('@database/db', () => { jest.mock('@utils/htmlParagraphExtractor', () => ({ extractParagraphs: jest.fn(() => ['Para 1', 'Para 2', 'Para 3']), + applyTtsTextCleanup: jest.fn((paragraphs: string[]) => paragraphs), + cleanTtsText: jest.fn((text: string) => text), })); jest.mock('@utils/Storages', () => ({ diff --git a/src/screens/reader/hooks/__tests__/useTTSProgressSync.test.ts b/src/screens/reader/hooks/__tests__/useTTSProgressSync.test.ts index 07eba152c3..a3065ead57 100644 --- a/src/screens/reader/hooks/__tests__/useTTSProgressSync.test.ts +++ b/src/screens/reader/hooks/__tests__/useTTSProgressSync.test.ts @@ -71,6 +71,8 @@ jest.mock('@utils/htmlParagraphExtractor', () => ({ 'Para 4', 'Para 5', ]), + applyTtsTextCleanup: jest.fn((paragraphs: string[]) => paragraphs), + cleanTtsText: jest.fn((text: string) => text), })); jest.mock('../../components/ttsHelpers', () => ({ validateAndClampParagraphIndex: jest.fn(idx => Math.max(0, idx)), diff --git a/src/screens/reader/hooks/__tests__/useTTSUtilities.test.ts b/src/screens/reader/hooks/__tests__/useTTSUtilities.test.ts index 7937a62ce2..9cd99daedc 100644 --- a/src/screens/reader/hooks/__tests__/useTTSUtilities.test.ts +++ b/src/screens/reader/hooks/__tests__/useTTSUtilities.test.ts @@ -14,7 +14,10 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useTTSUtilities } from '../useTTSUtilities'; import TTSHighlight from '@services/TTSHighlight'; import { MMKVStorage } from '@utils/mmkv/mmkv'; -import { extractParagraphs } from '@utils/htmlParagraphExtractor'; +import { + extractParagraphs, + applyTtsTextCleanup, +} from '@utils/htmlParagraphExtractor'; import { validateAndClampParagraphIndex } from '../../components/ttsHelpers'; // Mock dependencies @@ -37,6 +40,8 @@ jest.mock('@utils/mmkv/mmkv', () => ({ jest.mock('@utils/htmlParagraphExtractor', () => ({ extractParagraphs: jest.fn(), + applyTtsTextCleanup: jest.fn((paragraphs: string[]) => paragraphs), + cleanTtsText: jest.fn((text: string) => text), })); jest.mock('../../components/ttsHelpers', () => ({ @@ -47,6 +52,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { // Mock refs let mockWebViewRef: any; let mockReaderSettingsRef: any; + let mockChapterGeneralSettingsRef: any; let mockRefs: any; // Mock data @@ -75,6 +81,11 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { }, }; + // Setup chapter general settings ref + mockChapterGeneralSettingsRef = { + current: {}, + }; + // Setup all refs mockRefs = { currentParagraphIndexRef: { current: 0 }, @@ -108,6 +119,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -126,6 +138,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -153,6 +166,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -192,6 +206,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -217,6 +232,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -251,6 +267,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -277,6 +294,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -302,6 +320,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -327,6 +346,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -354,6 +374,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -379,6 +400,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -398,6 +420,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -417,6 +440,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -445,6 +469,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -457,6 +482,10 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { mockHtml, mockChapter.name, ); + expect(applyTtsTextCleanup).toHaveBeenCalledWith( + expect.any(Array), + mockChapterGeneralSettingsRef.current?.ttsTextCleanup, + ); }); it('should return early if no paragraphs extracted', async () => { @@ -469,6 +498,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -491,6 +521,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -518,6 +549,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -542,6 +574,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -566,6 +599,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -592,6 +626,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -625,6 +660,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -648,6 +684,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -676,6 +713,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); @@ -707,6 +745,7 @@ describe('useTTSUtilities (Phase 1 - Step 2)', () => { html: mockHtml, webViewRef: mockWebViewRef, readerSettingsRef: mockReaderSettingsRef, + chapterGeneralSettingsRef: mockChapterGeneralSettingsRef, refs: mockRefs, }), ); diff --git a/src/screens/reader/hooks/useTTSController.ts b/src/screens/reader/hooks/useTTSController.ts index b9051b2a3f..64ee5208c5 100644 --- a/src/screens/reader/hooks/useTTSController.ts +++ b/src/screens/reader/hooks/useTTSController.ts @@ -18,7 +18,11 @@ import TTSHighlight from '@services/TTSHighlight'; import TTSAudioManager from '@services/TTSAudioManager'; import { TTSState } from '@services/TTSState'; import { MMKVStorage, getMMKVObject } from '@utils/mmkv/mmkv'; -import { extractParagraphs } from '@utils/htmlParagraphExtractor'; +import { + extractParagraphs, + applyTtsTextCleanup, + cleanTtsText, +} from '@utils/htmlParagraphExtractor'; import { getChapter as getChapterFromDb, updateChapterProgress as updateChapterProgressDb, @@ -530,6 +534,7 @@ export function useTTSController( html, webViewRef, readerSettingsRef, + chapterGeneralSettingsRef, refs: { currentParagraphIndexRef, totalParagraphsRef, @@ -687,8 +692,11 @@ export function useTTSController( isWebViewSyncedRef.current = true; ttsCtrlLog.debug('webview-synced-background'); - // Extract paragraphs from HTML - const paragraphs = extractParagraphs(html, chapterName); + // Extract paragraphs from HTML (with TTS text cleanup applied) + const paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapterName), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); ttsCtrlLog.debug('background-paragraphs', `count=${paragraphs.length}`); if (paragraphs.length === 0) { @@ -782,7 +790,9 @@ export function useTTSController( }, [ chapterId, html, + chapterName, readerSettingsRef, + chapterGeneralSettingsRef, showToastMessage, updateTtsMediaNotificationState, restoreSavedEngine, @@ -824,7 +834,10 @@ export function useTTSController( MMKVStorage.getNumber(`chapter_progress_${chapterId}`) ?? -1; if (savedMMKVIndex >= 0) { - const paragraphs = extractParagraphs(html, chapterName); + const paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapterName), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); const totalParagraphs = paragraphs?.length || 0; // If saved index is beyond chapter bounds, it's likely an off-by-one error @@ -879,7 +892,7 @@ export function useTTSController( } } } - }, [chapterId, html]); + }, [chapterId, html, chapterName, chapterGeneralSettingsRef]); // =========================================================================== // Utility Functions @@ -1073,10 +1086,16 @@ export function useTTSController( } // UNIFIED BATCH MODE: Always use speakBatch - const textToSpeak = event.data as string; + const textToSpeak = cleanTtsText( + event.data as string, + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); let paragraphs: string[] = []; try { - paragraphs = extractParagraphs(html, chapterName); + paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapterName), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); } catch (e) { ttsCtrlLog.error('extract-paragraphs-failed', e); } @@ -1341,24 +1360,30 @@ export function useTTSController( } ttsCtrlLog.info('tts-queue-accept', `start=${incomingStart}`); + // Apply TTS text cleanup to the DOM-fed refill paragraphs (Path B) + // before they reach the native TTS engine. + const cleanedQueueTexts = applyTtsTextCleanup( + event.data as string[], + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); ttsQueueRef.current = { startIndex: event.startIndex, - texts: event.data as string[], + texts: cleanedQueueTexts, }; // Use batch TTS for background playback if ( chapterGeneralSettingsRef.current.ttsBackgroundPlayback && - event.data.length > 0 + cleanedQueueTexts.length > 0 ) { const startIndex = event.startIndex; - const utteranceIds = (event.data as string[]).map( + const utteranceIds = cleanedQueueTexts.map( (_, i) => `chapter_${chapterId}_utterance_${startIndex + i}`, ); ttsCtrlLog.debug( 'add-to-batch', - `Adding ${event.data.length} paragraphs to TTS queue from index ${startIndex}`, + `Adding ${cleanedQueueTexts.length} paragraphs to TTS queue from index ${startIndex}`, ); const addToBatchWithRetry = async ( @@ -1388,7 +1413,7 @@ export function useTTSController( return false; }; - addToBatchWithRetry(event.data as string[], utteranceIds) + addToBatchWithRetry(cleanedQueueTexts, utteranceIds) .then(success => { if (!success) { ttsCtrlLog.error( @@ -1417,9 +1442,11 @@ export function useTTSController( [ chapterId, html, + chapterName, webViewRef, readerSettingsRef, chapterGeneralSettingsRef, + restoreSavedEngine, navigation, handleRequestTTSConfirmation, showScrollSyncDialog, @@ -1477,7 +1504,10 @@ export function useTTSController( ); // Calculate progress info for the error dialog - const retryParagraphs = extractParagraphs(html, chapterName); + const retryParagraphs = applyTtsTextCleanup( + extractParagraphs(html, chapterName), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); const retryTotalParagraphs = retryParagraphs?.length ?? 0; const paragraphIdx = savedWakeParagraphIdx ?? 0; const progressPercent = @@ -1594,7 +1624,10 @@ export function useTTSController( savedWakeParagraphIdx, ); - const paragraphs = extractParagraphs(html, chapterName); + const paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapterName), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); if (paragraphs && paragraphs.length > savedWakeParagraphIdx) { // CRITICAL FIX (Bug #2): Inject scroll restoration BEFORE resuming playback // This ensures WebView scrolls to the correct paragraph when user returns from background @@ -1819,6 +1852,7 @@ export function useTTSController( }, [ chapterId, html, + chapterName, getChapter, webViewRef, readerSettingsRef, @@ -1834,11 +1868,19 @@ export function useTTSController( useEffect(() => { if (html) { - const paragraphs = extractParagraphs(html, chapterName); + const paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapterName), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); totalParagraphsRef.current = paragraphs?.length || 0; updateTtsMediaNotificationState(isTTSReadingRef.current); } - }, [html, updateTtsMediaNotificationState]); + }, [ + html, + chapterName, + updateTtsMediaNotificationState, + chapterGeneralSettingsRef, + ]); // =========================================================================== // Native TTS Event Listeners Effect @@ -3303,7 +3345,10 @@ export function useTTSController( if (idx >= 0) { // Attempt to resume using native batch playback try { - const paragraphs = extractParagraphs(html, chapterName); + const paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapterName), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); if (paragraphs && paragraphs.length > idx) { const remaining = paragraphs.slice(idx); const ids = remaining.map( diff --git a/src/screens/reader/hooks/useTTSUtilities.ts b/src/screens/reader/hooks/useTTSUtilities.ts index 307207efe3..4df00e849d 100644 --- a/src/screens/reader/hooks/useTTSUtilities.ts +++ b/src/screens/reader/hooks/useTTSUtilities.ts @@ -11,10 +11,16 @@ import { useCallback, RefObject } from 'react'; import WebView from 'react-native-webview'; import TTSHighlight from '@services/TTSHighlight'; import { MMKVStorage } from '@utils/mmkv/mmkv'; -import { extractParagraphs } from '@utils/htmlParagraphExtractor'; +import { + extractParagraphs, + applyTtsTextCleanup, +} from '@utils/htmlParagraphExtractor'; import { validateAndClampParagraphIndex } from '../components/ttsHelpers'; import { ChapterInfo, NovelInfo } from '@database/types'; -import { ChapterReaderSettings } from '@hooks/persisted/useSettings'; +import { + ChapterReaderSettings, + ChapterGeneralSettings, +} from '@hooks/persisted/useSettings'; import { TTSQueueState, TTSPersistenceState } from '../types/tts'; /** @@ -26,6 +32,7 @@ export interface TTSUtilitiesParams { html: string; webViewRef: RefObject; readerSettingsRef: RefObject; + chapterGeneralSettingsRef: RefObject; refs: { currentParagraphIndexRef: RefObject; totalParagraphsRef: RefObject; @@ -62,6 +69,7 @@ export function useTTSUtilities(params: TTSUtilitiesParams): TTSUtilities { html, webViewRef, readerSettingsRef, + chapterGeneralSettingsRef, refs: { currentParagraphIndexRef, totalParagraphsRef, @@ -140,7 +148,10 @@ export function useTTSUtilities(params: TTSUtilitiesParams): TTSUtilities { */ const restartTtsFromParagraphIndex = useCallback( async (targetIndex: number) => { - const paragraphs = extractParagraphs(html, chapter.name); + const paragraphs = applyTtsTextCleanup( + extractParagraphs(html, chapter.name), + chapterGeneralSettingsRef.current?.ttsTextCleanup, + ); if (!paragraphs || paragraphs.length === 0) return; const clamped = validateAndClampParagraphIndex( @@ -183,8 +194,10 @@ export function useTTSUtilities(params: TTSUtilitiesParams): TTSUtilities { }, [ chapter.id, + chapter.name, html, readerSettingsRef, + chapterGeneralSettingsRef, ttsQueueRef, currentParagraphIndexRef, latestParagraphIndexRef, diff --git a/src/screens/settings/SettingsReaderScreen/Modals/TtsTextCleanupModal.tsx b/src/screens/settings/SettingsReaderScreen/Modals/TtsTextCleanupModal.tsx new file mode 100644 index 0000000000..a856d43578 --- /dev/null +++ b/src/screens/settings/SettingsReaderScreen/Modals/TtsTextCleanupModal.tsx @@ -0,0 +1,851 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { StyleSheet, View, ScrollView, Dimensions, Share } from 'react-native'; +import { Portal, TextInput } from 'react-native-paper'; +import Modal from '@components/Modal/Modal'; +import List from '@components/List/List'; +import Switch from '@components/Switch/Switch'; +import AppText from '@components/AppText'; +import Button from '@components/Button/Button'; +import { IconButtonV2 } from '@components/index'; +import { useTheme, useAppSettings } from '@hooks/persisted'; +import { scaleDimension } from '@theme/scaling'; +import { + TtsTextCleanupSettings, + TtsCleanupRule, + TtsPhoneticPair, + createTtsCleanupRule, + createTtsPhoneticPair, + TTS_CLEANUP_MAX_REGEX_LENGTH, + isPotentiallyCatastrophic, + normalizeRegExpFlags, +} from '@utils/htmlParagraphExtractor'; +import { + TTS_CLEANUP_PRESETS, + TtsCleanupPreset, + applyPresetToSettings, + parseCleanupSettingsImport, + serializeCleanupSettings, +} from './ttsCleanupPresets'; + +interface TtsTextCleanupModalProps { + visible: boolean; + onDismiss: () => void; + settings: TtsTextCleanupSettings; + onSave: (settings: TtsTextCleanupSettings) => void; +} + +type EditorMode = 'list' | 'rule' | 'pair'; + +interface RuleFormState { + id?: string; + pattern: string; + replacement: string; + isRegex: boolean; + flags: string; +} + +interface PairFormState { + id?: string; + word: string; + pronunciation: string; + matchMode: 'whole-word' | 'substring'; +} + +const EMPTY_RULE_FORM: RuleFormState = { + pattern: '', + replacement: '', + isRegex: false, + flags: 'g', +}; + +const EMPTY_PAIR_FORM: PairFormState = { + word: '', + pronunciation: '', + matchMode: 'whole-word', +}; + +/** Validate a rule form; returns an error string or null when valid. */ +const validateRuleForm = (form: RuleFormState): string | null => { + if (!form.pattern.trim()) { + return 'Pattern is required.'; + } + if (!form.isRegex) { + return null; + } + const pattern = form.pattern.trim(); + if (pattern.length > TTS_CLEANUP_MAX_REGEX_LENGTH) { + return `Regex is too long (max ${TTS_CLEANUP_MAX_REGEX_LENGTH} chars).`; + } + if (isPotentiallyCatastrophic(pattern)) { + return 'Pattern looks unsafe and may freeze playback (e.g. nested quantifiers like (a+)+). Simplify it.'; + } + try { + // Compile-check the pattern + flags exactly as the runtime will apply it. + new RegExp(pattern, normalizeRegExpFlags(form.flags)); + } catch { + return 'Invalid regex pattern or flags.'; + } + return null; +}; + +const formatRuleSummary = (rule: TtsCleanupRule): string => { + const find = rule.isRegex + ? `/${rule.pattern}/${rule.flags}` + : `"${rule.pattern}"`; + const replace = rule.replacement === '' ? '(strip)' : `"${rule.replacement}"`; + return `${find} → ${replace}`; +}; + +const formatPairSummary = (pair: TtsPhoneticPair): string => + `"${pair.word}" → "${pair.pronunciation}"`; + +/** + * Coerce possibly-partial stored settings (e.g. MMKV written by an older or + * interrupted version missing `rules`/`phoneticPairs`) into a fully-shaped + * object so every draft access below can assume the arrays exist. + */ +const normalizeSettings = ( + settings: TtsTextCleanupSettings, +): TtsTextCleanupSettings => ({ + enabled: !!settings.enabled, + normalizeUnicode: !!settings.normalizeUnicode, + rules: settings.rules ?? [], + phoneticPairs: settings.phoneticPairs ?? [], +}); + +const TtsTextCleanupModal: React.FC = ({ + visible, + onDismiss, + settings, + onSave, +}) => { + const theme = useTheme(); + const { uiScale = 1.0 } = useAppSettings(); + + const [draft, setDraft] = useState(() => + normalizeSettings(settings), + ); + const [mode, setMode] = useState('list'); + const [ruleForm, setRuleForm] = useState(EMPTY_RULE_FORM); + const [pairForm, setPairForm] = useState(EMPTY_PAIR_FORM); + const [ruleFormError, setRuleFormError] = useState(null); + const [importVisible, setImportVisible] = useState(false); + const [importText, setImportText] = useState(''); + const [importFeedback, setImportFeedback] = useState<{ + kind: 'error' | 'success'; + message: string; + } | null>(null); + + // Re-sync draft whenever the modal opens or settings change externally. + useEffect(() => { + if (visible) { + setDraft(normalizeSettings(settings)); + setMode('list'); + setRuleForm(EMPTY_RULE_FORM); + setPairForm(EMPTY_PAIR_FORM); + setRuleFormError(null); + setImportVisible(false); + setImportText(''); + setImportFeedback(null); + } + }, [visible, settings]); + + const styles = useMemo( + () => + StyleSheet.create({ + containerStyle: { + maxHeight: Math.round(Dimensions.get('window').height * 0.75), + }, + content: { + paddingBottom: scaleDimension(16, uiScale), + }, + section: { + marginTop: scaleDimension(16, uiScale), + }, + masterRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: scaleDimension(8, uiScale), + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: scaleDimension(6, uiScale), + }, + toggleLabel: { + flex: 1, + paddingRight: scaleDimension(12, uiScale), + }, + hint: { + fontSize: scaleDimension(12, uiScale), + marginTop: 2, + }, + ruleRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: scaleDimension(4, uiScale), + gap: scaleDimension(4, uiScale), + }, + ruleText: { + flex: 1, + fontSize: scaleDimension(13, uiScale), + }, + form: { + marginTop: scaleDimension(12, uiScale), + }, + formField: { + marginBottom: scaleDimension(8, uiScale), + backgroundColor: theme.surface2, + }, + formActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: scaleDimension(8, uiScale), + marginTop: scaleDimension(8, uiScale), + }, + emptyText: { + paddingVertical: scaleDimension(8, uiScale), + }, + formError: { + fontSize: scaleDimension(12, uiScale), + marginBottom: scaleDimension(8, uiScale), + }, + feedbackText: { + fontSize: scaleDimension(12, uiScale), + marginTop: scaleDimension(6, uiScale), + }, + presetRow: { + flexDirection: 'row', + alignItems: 'center', + gap: scaleDimension(8, uiScale), + paddingVertical: scaleDimension(6, uiScale), + }, + shareRow: { + flexDirection: 'row', + justifyContent: 'flex-start', + gap: scaleDimension(4, uiScale), + marginTop: scaleDimension(8, uiScale), + }, + addButtonContainer: { + marginTop: scaleDimension(8, uiScale), + }, + }), + [uiScale, theme.surface2], + ); + + const inputProps = { + mode: 'flat' as const, + autoCorrect: false, + autoCapitalize: 'none' as const, + spellCheck: false, + activeUnderlineColor: theme.primary, + textColor: theme.onSurface, + placeholderTextColor: theme.onSurfaceVariant, + }; + + const updateRule = (id: string, patch: Partial) => { + setDraft(d => ({ + ...d, + rules: d.rules.map(r => (r.id === id ? { ...r, ...patch } : r)), + })); + }; + + const removeRule = (id: string) => { + setDraft(d => ({ ...d, rules: d.rules.filter(r => r.id !== id) })); + }; + + const updatePair = (id: string, patch: Partial) => { + setDraft(d => ({ + ...d, + phoneticPairs: d.phoneticPairs.map(p => + p.id === id ? { ...p, ...patch } : p, + ), + })); + }; + + const moveRule = (id: string, dir: -1 | 1) => { + setDraft(d => { + const idx = d.rules.findIndex(r => r.id === id); + const target = idx + dir; + if (idx < 0 || target < 0 || target >= d.rules.length) { + return d; + } + const rules = [...d.rules]; + [rules[idx], rules[target]] = [rules[target], rules[idx]]; + return { ...d, rules }; + }); + }; + + const movePair = (id: string, dir: -1 | 1) => { + setDraft(d => { + const idx = d.phoneticPairs.findIndex(p => p.id === id); + const target = idx + dir; + if (idx < 0 || target < 0 || target >= d.phoneticPairs.length) { + return d; + } + const phoneticPairs = [...d.phoneticPairs]; + [phoneticPairs[idx], phoneticPairs[target]] = [ + phoneticPairs[target], + phoneticPairs[idx], + ]; + return { ...d, phoneticPairs }; + }); + }; + + const removePair = (id: string) => { + setDraft(d => ({ + ...d, + phoneticPairs: d.phoneticPairs.filter(p => p.id !== id), + })); + }; + + const saveRule = () => { + const validationError = validateRuleForm(ruleForm); + if (validationError) { + setRuleFormError(validationError); + return; + } + setRuleFormError(null); + setDraft(d => { + if (ruleForm.id) { + return { + ...d, + rules: d.rules.map(r => + r.id === ruleForm.id + ? { + ...r, + pattern: ruleForm.pattern.trim(), + replacement: ruleForm.replacement, + isRegex: ruleForm.isRegex, + flags: ruleForm.isRegex + ? normalizeRegExpFlags(ruleForm.flags) + : ruleForm.flags, + } + : r, + ), + }; + } + return { + ...d, + rules: [ + ...d.rules, + createTtsCleanupRule( + ruleForm.pattern.trim(), + ruleForm.replacement, + ruleForm.isRegex, + ruleForm.isRegex + ? normalizeRegExpFlags(ruleForm.flags) + : ruleForm.flags, + ), + ], + }; + }); + setMode('list'); + setRuleForm(EMPTY_RULE_FORM); + }; + + const savePair = () => { + if (!pairForm.word.trim()) { + return; + } + setDraft(d => { + if (pairForm.id) { + return { + ...d, + phoneticPairs: d.phoneticPairs.map(p => + p.id === pairForm.id + ? { + ...p, + word: pairForm.word.trim(), + pronunciation: pairForm.pronunciation, + matchMode: pairForm.matchMode, + } + : p, + ), + }; + } + return { + ...d, + phoneticPairs: [ + ...d.phoneticPairs, + createTtsPhoneticPair( + pairForm.word.trim(), + pairForm.pronunciation, + true, + pairForm.matchMode, + ), + ], + }; + }); + setMode('list'); + setPairForm(EMPTY_PAIR_FORM); + }; + + const handleApplyPreset = (preset: TtsCleanupPreset) => { + setDraft(d => applyPresetToSettings(d, preset)); + }; + + const handleExport = () => { + Share.share({ message: serializeCleanupSettings(draft) }).catch(() => { + // User dismissed the share sheet — nothing to do. + }); + }; + + const handleImport = () => { + const result = parseCleanupSettingsImport(importText, draft); + if (result.ok) { + setDraft(result.settings); + setImportFeedback({ kind: 'success', message: result.summary }); + setImportText(''); + } else { + setImportFeedback({ kind: 'error', message: result.error }); + } + }; + + return ( + + + + + + + + Clean TTS text + + + Applied to every paragraph before it reaches the TTS engine + + + + setDraft(d => ({ ...d, enabled: !d.enabled })) + } + /> + + + + + + Unicode normalization + + + NFD normalize + strip combining marks + + + + setDraft(d => ({ + ...d, + normalizeUnicode: !d.normalizeUnicode, + })) + } + /> + + + {/* Import / Export */} + +