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 */}
+
+
+ setImportVisible(v => !v)}
+ />
+
+ {importVisible && (
+
+
+ Paste a TTS Text Cleanup JSON export. Import replaces the
+ current rules & phonetic pairs (draft only — Cancel
+ discards).
+
+
+
+
+
+ {importFeedback && (
+
+ {importFeedback.message}
+
+ )}
+
+ )}
+
+ {mode === 'list' && (
+
+ Presets (one-tap)
+ {TTS_CLEANUP_PRESETS.map(preset => (
+
+
+
+ {preset.title}
+
+
+ {preset.description}
+
+
+ handleApplyPreset(preset)}
+ />
+
+ ))}
+
+ )}
+
+ {mode === 'rule' ? (
+
+
+ setRuleForm(f => ({ ...f, pattern: text }))
+ }
+ style={styles.formField}
+ />
+
+ setRuleForm(f => ({ ...f, replacement: text }))
+ }
+ style={styles.formField}
+ />
+
+
+
+ Treat as regex
+
+
+ Invalid regexes are skipped automatically
+
+
+
+ setRuleForm(f => ({ ...f, isRegex: !f.isRegex }))
+ }
+ />
+
+ {ruleForm.isRegex && (
+ <>
+
+ setRuleForm(f => ({ ...f, flags: text }))
+ }
+ style={styles.formField}
+ />
+
+ Note: regex patterns match text after Unicode
+ normalization — precomposed characters (e.g. é) will not
+ match normalized text. Use the decomposed form or disable
+ normalization.
+
+ >
+ )}
+ {ruleFormError && (
+
+ {ruleFormError}
+
+ )}
+
+ {
+ setMode('list');
+ setRuleForm(EMPTY_RULE_FORM);
+ setRuleFormError(null);
+ }}
+ />
+
+
+
+ ) : (
+
+
+ Find & Replace Rules
+
+ {draft.rules.length === 0 ? (
+
+ No rules yet. Add rules to strip watermarks, fix corrupted
+ text (e.g. "u2014"), or replace lookalike characters.
+
+ ) : (
+ draft.rules.map((rule, index) => (
+
+
+ updateRule(rule.id, { enabled: !rule.enabled })
+ }
+ />
+
+ {formatRuleSummary(rule)}
+
+ moveRule(rule.id, -1)}
+ />
+ moveRule(rule.id, 1)}
+ />
+ {
+ setRuleForm({
+ id: rule.id,
+ pattern: rule.pattern,
+ replacement: rule.replacement,
+ isRegex: rule.isRegex,
+ flags: rule.flags,
+ });
+ setRuleFormError(null);
+ setMode('rule');
+ }}
+ />
+ removeRule(rule.id)}
+ />
+
+ ))
+ )}
+
+ {
+ setRuleForm(EMPTY_RULE_FORM);
+ setRuleFormError(null);
+ setMode('rule');
+ }}
+ />
+
+
+ )}
+
+ {mode === 'pair' ? (
+
+
+ setPairForm(f => ({ ...f, word: text }))
+ }
+ style={styles.formField}
+ />
+
+ setPairForm(f => ({ ...f, pronunciation: text }))
+ }
+ style={styles.formField}
+ />
+
+
+
+ Replace every occurrence (substring)
+
+
+ Needed for unspaced CJK text; off = whole-word only
+
+
+
+ setPairForm(f => ({
+ ...f,
+ matchMode:
+ f.matchMode === 'substring'
+ ? 'whole-word'
+ : 'substring',
+ }))
+ }
+ />
+
+
+ {
+ setMode('list');
+ setPairForm(EMPTY_PAIR_FORM);
+ }}
+ />
+
+
+
+ ) : (
+
+
+ Phonetic Dictionary
+
+ {draft.phoneticPairs.length === 0 ? (
+
+ No phonetic swaps yet. Add name/honorific pronunciation
+ fixes (e.g. "Xianxia" → "Shee-an-shah").
+
+ ) : (
+ draft.phoneticPairs.map((pair, index) => (
+
+
+ updatePair(pair.id, { enabled: !pair.enabled })
+ }
+ />
+
+ {formatPairSummary(pair)}
+
+ movePair(pair.id, -1)}
+ />
+ movePair(pair.id, 1)}
+ />
+ {
+ setPairForm({
+ id: pair.id,
+ word: pair.word,
+ pronunciation: pair.pronunciation,
+ matchMode: pair.matchMode ?? 'whole-word',
+ });
+ setMode('pair');
+ }}
+ />
+ removePair(pair.id)}
+ />
+
+ ))
+ )}
+
+ {
+ setPairForm(EMPTY_PAIR_FORM);
+ setMode('pair');
+ }}
+ />
+
+
+ )}
+
+
+
+ {
+ onSave(draft);
+ onDismiss();
+ }}
+ />
+
+
+
+
+
+ );
+};
+
+export default TtsTextCleanupModal;
diff --git a/src/screens/settings/SettingsReaderScreen/Modals/__tests__/ttsCleanupPresets.test.ts b/src/screens/settings/SettingsReaderScreen/Modals/__tests__/ttsCleanupPresets.test.ts
new file mode 100644
index 0000000000..3637da98c0
--- /dev/null
+++ b/src/screens/settings/SettingsReaderScreen/Modals/__tests__/ttsCleanupPresets.test.ts
@@ -0,0 +1,322 @@
+import {
+ TTS_CLEANUP_PRESETS,
+ applyPresetToSettings,
+ serializeCleanupSettings,
+ parseCleanupSettingsImport,
+ TTS_CLEANUP_IMPORT_FORMAT,
+ TTS_CLEANUP_IMPORT_VERSION,
+} from '../ttsCleanupPresets';
+import {
+ TtsTextCleanupSettings,
+ createTtsCleanupRule,
+ createTtsPhoneticPair,
+ DEFAULT_TTS_CLEANUP_SETTINGS,
+ isPotentiallyCatastrophic,
+ TTS_CLEANUP_MAX_REGEX_LENGTH,
+} from '@utils/htmlParagraphExtractor';
+
+const buildSettings = (
+ overrides: Partial = {},
+): TtsTextCleanupSettings => ({
+ ...DEFAULT_TTS_CLEANUP_SETTINGS,
+ ...overrides,
+});
+
+describe('TTS_CLEANUP_PRESETS', () => {
+ it('provides a non-empty curated set with valid shape', () => {
+ expect(TTS_CLEANUP_PRESETS.length).toBeGreaterThan(0);
+ for (const preset of TTS_CLEANUP_PRESETS) {
+ expect(preset.id).toBeTruthy();
+ expect(preset.title).toBeTruthy();
+ expect(preset.description).toBeTruthy();
+ expect(Array.isArray(preset.rules)).toBe(true);
+ expect(Array.isArray(preset.phoneticPairs)).toBe(true);
+ }
+ });
+
+ it('ships only safe regexes (within length cap, not catastrophic)', () => {
+ for (const preset of TTS_CLEANUP_PRESETS) {
+ for (const rule of preset.rules) {
+ if (!rule.isRegex) {
+ continue;
+ }
+ expect(rule.pattern.length).toBeLessThanOrEqual(
+ TTS_CLEANUP_MAX_REGEX_LENGTH,
+ );
+ expect(isPotentiallyCatastrophic(rule.pattern)).toBe(false);
+ }
+ }
+ });
+
+ it('ships at least one phonetic preset with whole-word and substring modes', () => {
+ const allPairs = TTS_CLEANUP_PRESETS.flatMap(p => p.phoneticPairs);
+ expect(
+ allPairs.some(p => (p.matchMode ?? 'whole-word') === 'whole-word'),
+ ).toBe(true);
+ expect(allPairs.some(p => p.matchMode === 'substring')).toBe(true);
+ });
+});
+
+describe('applyPresetToSettings', () => {
+ it('adds preset rules and phonetic pairs', () => {
+ const preset = TTS_CLEANUP_PRESETS[0]; // novelight watermark
+ const settings = buildSettings();
+ const result = applyPresetToSettings(settings, preset);
+ expect(result.rules).toHaveLength(preset.rules.length);
+ expect(result.phoneticPairs).toHaveLength(0);
+ });
+
+ it('is immutable (does not mutate the input)', () => {
+ const settings = buildSettings();
+ const snapshot = JSON.stringify(settings);
+ applyPresetToSettings(settings, TTS_CLEANUP_PRESETS[0]);
+ expect(JSON.stringify(settings)).toBe(snapshot);
+ });
+
+ it('dedupes rules with the same pattern + isRegex', () => {
+ const existing = createTtsCleanupRule(
+ 'N(?:\\W)*o(?:\\W)*v(?:\\W)*e(?:\\W)*l(?:\\W)*i(?:\\W)*g(?:\\W)*h(?:\\W)*t',
+ '',
+ true,
+ 'gi',
+ );
+ const settings = buildSettings({ rules: [existing] });
+ const result = applyPresetToSettings(settings, TTS_CLEANUP_PRESETS[0]);
+ expect(result.rules).toHaveLength(1);
+ });
+
+ it('keeps distinct rules with the same pattern but different isRegex', () => {
+ const literal = createTtsCleanupRule('u2014', ' '); // literal
+ const settings = buildSettings({ rules: [literal] });
+ const preset = TTS_CLEANUP_PRESETS.find(p => p.id === 'corruption-u2014')!;
+ // Preset adds a literal 'u2014' too — must be deduped.
+ const result = applyPresetToSettings(settings, preset);
+ expect(result.rules).toHaveLength(1);
+ });
+
+ it('dedupes phonetic pairs by word + matchMode', () => {
+ const pair = createTtsPhoneticPair('Xianxia', 'Shee-an-shah');
+ const settings = buildSettings({ phoneticPairs: [pair] });
+ const preset = TTS_CLEANUP_PRESETS.find(
+ p => p.id === 'phonetics-ln-names',
+ )!;
+ const result = applyPresetToSettings(settings, preset);
+ expect(result.phoneticPairs).toHaveLength(3); // Xianxia deduped, 2 new
+ });
+
+ it('preserves existing rules when applying multiple presets', () => {
+ const settings = buildSettings();
+ const one = applyPresetToSettings(settings, TTS_CLEANUP_PRESETS[0]);
+ const two = applyPresetToSettings(one, TTS_CLEANUP_PRESETS[1]);
+ expect(two.rules.length).toBeGreaterThan(one.rules.length);
+ });
+});
+
+describe('serializeCleanupSettings', () => {
+ it('produces a versioned JSON envelope that round-trips', () => {
+ const settings = buildSettings({
+ enabled: true,
+ normalizeUnicode: true,
+ rules: [createTtsCleanupRule('u2014', ' ')],
+ phoneticPairs: [createTtsPhoneticPair('Qing', 'Ching')],
+ });
+ const json = serializeCleanupSettings(settings);
+ const parsed = JSON.parse(json);
+ expect(parsed.format).toBe(TTS_CLEANUP_IMPORT_FORMAT);
+ expect(parsed.version).toBe(TTS_CLEANUP_IMPORT_VERSION);
+ expect(parsed.settings.rules[0].pattern).toBe('u2014');
+ expect(parsed.settings.phoneticPairs[0].word).toBe('Qing');
+ });
+});
+
+describe('parseCleanupSettingsImport', () => {
+ const current = buildSettings({ enabled: true, normalizeUnicode: true });
+
+ it('round-trips a serialize -> parse (ids regenerated, fields intact)', () => {
+ const settings = buildSettings({
+ enabled: true,
+ normalizeUnicode: false,
+ rules: [
+ createTtsCleanupRule('u2014', ' ', false, 'g', true),
+ createTtsCleanupRule('\\(Official version\\)\\s*', '', true, 'g'),
+ ],
+ phoneticPairs: [
+ createTtsPhoneticPair('Xianxia', 'Shee-an-shah'),
+ createTtsPhoneticPair('秦', 'Qin', true, 'substring'),
+ ],
+ });
+ const result = parseCleanupSettingsImport(
+ serializeCleanupSettings(settings),
+ current,
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) {
+ return;
+ }
+ expect(result.settings.enabled).toBe(true);
+ expect(result.settings.normalizeUnicode).toBe(false);
+ expect(result.settings.rules).toHaveLength(2);
+ expect(result.settings.rules[0].pattern).toBe('u2014');
+ expect(result.settings.rules[1].isRegex).toBe(true);
+ expect(result.settings.phoneticPairs).toHaveLength(2);
+ expect(result.settings.phoneticPairs[1].matchMode).toBe('substring');
+ // Fresh ids on import
+ expect(result.settings.rules[0].id).not.toBe(settings.rules[0].id);
+ });
+
+ it('accepts a bare settings object (no envelope)', () => {
+ const bare = {
+ enabled: true,
+ normalizeUnicode: false,
+ rules: [{ pattern: 'u2014', replacement: ' ' }],
+ phoneticPairs: [],
+ };
+ const result = parseCleanupSettingsImport(JSON.stringify(bare), current);
+ expect(result.ok).toBe(true);
+ if (!result.ok) {
+ return;
+ }
+ expect(result.settings.rules[0].pattern).toBe('u2014');
+ expect(result.settings.rules[0].isRegex).toBe(false);
+ });
+
+ it('rejects invalid JSON', () => {
+ const result = parseCleanupSettingsImport('{not json', current);
+ expect(result.ok).toBe(false);
+ if (result.ok) {
+ return;
+ }
+ expect(result.error).toContain('Invalid JSON');
+ });
+
+ it('rejects arrays and non-object payloads', () => {
+ expect(parseCleanupSettingsImport('[1,2,3]', current).ok).toBe(false);
+ expect(parseCleanupSettingsImport('"text"', current).ok).toBe(false);
+ expect(parseCleanupSettingsImport('42', current).ok).toBe(false);
+ });
+
+ it('rejects wrong-typed rules / phoneticPairs', () => {
+ expect(
+ parseCleanupSettingsImport(
+ JSON.stringify({ rules: 'nope', phoneticPairs: [] }),
+ current,
+ ).ok,
+ ).toBe(false);
+ expect(
+ parseCleanupSettingsImport(
+ JSON.stringify({ rules: [], phoneticPairs: 7 }),
+ current,
+ ).ok,
+ ).toBe(false);
+ });
+
+ it('skips invalid regexes and reports them in the summary', () => {
+ const payload = {
+ enabled: true,
+ rules: [
+ { pattern: '([unclosed', isRegex: true }, // invalid
+ { pattern: 'valid', isRegex: true }, // valid
+ { pattern: '' }, // empty -> skipped
+ { pattern: 42 }, // non-string -> skipped
+ ],
+ phoneticPairs: [],
+ };
+ const result = parseCleanupSettingsImport(JSON.stringify(payload), current);
+ expect(result.ok).toBe(true);
+ if (!result.ok) {
+ return;
+ }
+ expect(result.settings.rules).toHaveLength(1);
+ expect(result.settings.rules[0].pattern).toBe('valid');
+ expect(result.summary).toContain('3 invalid rules skipped');
+ });
+
+ it('skips over-length regexes', () => {
+ const longPattern = 'a'.repeat(TTS_CLEANUP_MAX_REGEX_LENGTH + 1);
+ const payload = {
+ rules: [{ pattern: longPattern, isRegex: true }],
+ phoneticPairs: [],
+ };
+ const result = parseCleanupSettingsImport(JSON.stringify(payload), current);
+ expect(result.ok).toBe(true);
+ if (!result.ok) {
+ return;
+ }
+ expect(result.settings.rules).toHaveLength(0);
+ });
+
+ it('coerces non-string replacements and invalid matchMode', () => {
+ const payload = {
+ rules: [{ pattern: 'x', replacement: 5 }],
+ phoneticPairs: [
+ { word: 'Qing', pronunciation: 'Ching', matchMode: 'bogus' },
+ ],
+ };
+ const result = parseCleanupSettingsImport(JSON.stringify(payload), current);
+ expect(result.ok).toBe(true);
+ if (!result.ok) {
+ return;
+ }
+ expect(result.settings.rules[0].replacement).toBe('');
+ expect(result.settings.phoneticPairs[0].matchMode).toBe('whole-word');
+ });
+
+ it('preserves enabled flags when provided, else falls back to current', () => {
+ const withFlags = parseCleanupSettingsImport(
+ JSON.stringify({
+ enabled: false,
+ normalizeUnicode: true,
+ rules: [],
+ phoneticPairs: [],
+ }),
+ current,
+ );
+ expect(withFlags.ok).toBe(true);
+ if (!withFlags.ok) {
+ return;
+ }
+ expect(withFlags.settings.enabled).toBe(false);
+ expect(withFlags.settings.normalizeUnicode).toBe(true);
+
+ const withoutFlags = parseCleanupSettingsImport(
+ JSON.stringify({ rules: [], phoneticPairs: [] }),
+ current,
+ );
+ expect(withoutFlags.ok).toBe(true);
+ if (!withoutFlags.ok) {
+ return;
+ }
+ expect(withoutFlags.settings.enabled).toBe(true); // from current
+ expect(withoutFlags.settings.normalizeUnicode).toBe(true); // from current
+ });
+
+ it('accepts an empty import (clears rules) with a summary', () => {
+ const result = parseCleanupSettingsImport(
+ JSON.stringify({ rules: [], phoneticPairs: [] }),
+ current,
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) {
+ return;
+ }
+ expect(result.settings.rules).toHaveLength(0);
+ expect(result.settings.phoneticPairs).toHaveLength(0);
+ expect(result.summary).toContain('Imported 0 rules');
+ });
+
+ it('never trusts imported ids (regenerates them)', () => {
+ const payload = {
+ rules: [{ id: 'evil-id', pattern: 'u2014', replacement: ' ' }],
+ phoneticPairs: [
+ { id: 'evil-id-2', word: 'Qing', pronunciation: 'Ching' },
+ ],
+ };
+ const result = parseCleanupSettingsImport(JSON.stringify(payload), current);
+ expect(result.ok).toBe(true);
+ if (!result.ok) {
+ return;
+ }
+ expect(result.settings.rules[0].id).not.toBe('evil-id');
+ expect(result.settings.phoneticPairs[0].id).not.toBe('evil-id-2');
+ });
+});
diff --git a/src/screens/settings/SettingsReaderScreen/Modals/ttsCleanupPresets.ts b/src/screens/settings/SettingsReaderScreen/Modals/ttsCleanupPresets.ts
new file mode 100644
index 0000000000..3a1da75b1b
--- /dev/null
+++ b/src/screens/settings/SettingsReaderScreen/Modals/ttsCleanupPresets.ts
@@ -0,0 +1,312 @@
+import {
+ TtsTextCleanupSettings,
+ TtsCleanupRule,
+ TtsPhoneticPair,
+ createTtsCleanupRule,
+ createTtsPhoneticPair,
+ normalizeRegExpFlags,
+ TTS_CLEANUP_MAX_REGEX_LENGTH,
+} from '@utils/htmlParagraphExtractor';
+
+// =============================================================================
+// TTS Text Cleanup — Presets + Import/Export
+//
+// Presets are SHIPPED AS DATA ONLY. They are never executed by the cleanup
+// pipeline: applying a preset copies its rules/phonetic pairs into the user's
+// own settings (MMKV), where they stay fully editable. This keeps the core
+// pipeline site-agnostic while giving users one-tap onboarding for the common
+// watermark / pronunciation fixes (see PR_17 §10).
+// =============================================================================
+
+export interface TtsCleanupPreset {
+ id: string;
+ title: string;
+ description: string;
+ rules: TtsCleanupRule[];
+ phoneticPairs: TtsPhoneticPair[];
+}
+
+export const TTS_CLEANUP_PRESETS: TtsCleanupPreset[] = [
+ {
+ id: 'watermark-novelight',
+ title: 'Novelight watermark (spaced letters)',
+ description:
+ 'Strips the spaced-out "Novelight" anti-scraper watermark found on sites like Novelight.',
+ rules: [
+ createTtsCleanupRule(
+ 'N(?:\\W)*o(?:\\W)*v(?:\\W)*e(?:\\W)*l(?:\\W)*i(?:\\W)*g(?:\\W)*h(?:\\W)*t',
+ '',
+ true,
+ 'gi',
+ ),
+ ],
+ phoneticPairs: [],
+ },
+ {
+ id: 'corruption-u2014',
+ title: 'Fix "u2014" text corruption',
+ description:
+ 'Replaces the literal "u2014" string (corrupted sentence spacing) with a space.',
+ rules: [createTtsCleanupRule('u2014', ' ')],
+ phoneticPairs: [],
+ },
+ {
+ id: 'tag-official-version',
+ title: 'Strip "(Official version)" tags',
+ description:
+ 'Removes "(Official version)" tags and the whitespace after them.',
+ rules: [createTtsCleanupRule('\\(Official version\\)\\s*', '', true, 'g')],
+ phoneticPairs: [],
+ },
+ {
+ id: 'spam-do-not-rehost',
+ title: 'Strip "Do not rehost this novel" spam',
+ description:
+ 'Removes repeated "Do not rehost this novel" anti-scraper spam phrases.',
+ rules: [
+ createTtsCleanupRule('(Do not rehost this novel)+', '', true, 'gi'),
+ ],
+ phoneticPairs: [],
+ },
+ {
+ id: 'lookalike-math-bold',
+ title: 'Fix math-bold lookalike letters',
+ description:
+ 'Maps unicode mathematical bold/script letters (e.g. mathematical bold N) back to normal letters.',
+ rules: [
+ createTtsCleanupRule('\u{1D40D}', 'N'), // mathematical bold capital N
+ createTtsCleanupRule('\u{1D4DD}', 'N'), // mathematical script capital N
+ createTtsCleanupRule('\u{1D400}', 'A'), // mathematical bold capital A
+ ],
+ phoneticPairs: [],
+ },
+ {
+ id: 'phonetics-ln-names',
+ title: 'LN name pronunciations',
+ description:
+ 'Common light-novel names/honorifics that system engines mispronounce.',
+ rules: [],
+ phoneticPairs: [
+ createTtsPhoneticPair('Xianxia', 'Shee-an-shah'),
+ createTtsPhoneticPair('Qing', 'Ching'),
+ createTtsPhoneticPair('Ainz', 'Ownz'),
+ ],
+ },
+ {
+ id: 'phonetics-cjk',
+ title: 'CJK name pronunciation (substring)',
+ description:
+ 'Pronounces common CJK characters used in names. Substring mode is required because whole-word boundaries never fire between adjacent CJK characters.',
+ rules: [],
+ phoneticPairs: [
+ createTtsPhoneticPair('秦', 'Qin', true, 'substring'),
+ createTtsPhoneticPair('卿', 'Qing', true, 'substring'),
+ createTtsPhoneticPair('楚', 'Chu', true, 'substring'),
+ ],
+ },
+];
+
+/**
+ * Merge a preset into settings, skipping rules/pairs that already exist
+ * (deduped by pattern+isRegex / word+matchMode). Immutable — returns a new
+ * settings object.
+ */
+export function applyPresetToSettings(
+ settings: TtsTextCleanupSettings,
+ preset: TtsCleanupPreset,
+): TtsTextCleanupSettings {
+ const rules = [...(settings.rules ?? [])];
+ for (const rule of preset.rules) {
+ const alreadyExists = rules.some(
+ r => r.pattern === rule.pattern && r.isRegex === rule.isRegex,
+ );
+ if (!alreadyExists) {
+ rules.push(rule);
+ }
+ }
+
+ const phoneticPairs = [...(settings.phoneticPairs ?? [])];
+ for (const pair of preset.phoneticPairs) {
+ const alreadyExists = phoneticPairs.some(
+ p =>
+ p.word === pair.word &&
+ (p.matchMode ?? 'whole-word') === (pair.matchMode ?? 'whole-word'),
+ );
+ if (!alreadyExists) {
+ phoneticPairs.push(pair);
+ }
+ }
+
+ return { ...settings, rules, phoneticPairs };
+}
+
+// -----------------------------------------------------------------------------
+// Import / Export (JSON)
+// -----------------------------------------------------------------------------
+
+export const TTS_CLEANUP_IMPORT_FORMAT = 'lnreader-tts-cleanup';
+export const TTS_CLEANUP_IMPORT_VERSION = 1;
+
+/** Serialize settings to a versioned JSON envelope for sharing/backup. */
+export function serializeCleanupSettings(
+ settings: TtsTextCleanupSettings,
+): string {
+ return JSON.stringify(
+ {
+ format: TTS_CLEANUP_IMPORT_FORMAT,
+ version: TTS_CLEANUP_IMPORT_VERSION,
+ settings,
+ },
+ null,
+ 2,
+ );
+}
+
+export type CleanupImportResult =
+ | { ok: true; settings: TtsTextCleanupSettings; summary: string }
+ | { ok: false; error: string };
+
+/**
+ * Parse + validate an imported JSON payload (versioned envelope OR a bare
+ * settings object). Rules/pairs are sanitized and get fresh ids; invalid
+ * regexes and empty patterns are skipped (counted in the summary). Boolean
+ * flags missing from the payload fall back to the current settings.
+ */
+export function parseCleanupSettingsImport(
+ text: string,
+ current: TtsTextCleanupSettings,
+): CleanupImportResult {
+ let payload: unknown;
+ try {
+ payload = JSON.parse(text);
+ } catch {
+ return {
+ ok: false,
+ error: 'Invalid JSON — paste a valid TTS Text Cleanup export.',
+ };
+ }
+
+ if (
+ typeof payload !== 'object' ||
+ payload === null ||
+ Array.isArray(payload)
+ ) {
+ return {
+ ok: false,
+ error: 'Unsupported format — expected a TTS Text Cleanup export object.',
+ };
+ }
+
+ const raw = payload as Record;
+ const settingsRaw =
+ raw.format === TTS_CLEANUP_IMPORT_FORMAT &&
+ typeof raw.settings === 'object' &&
+ raw.settings !== null
+ ? (raw.settings as Record)
+ : raw;
+
+ if (settingsRaw.rules !== undefined && !Array.isArray(settingsRaw.rules)) {
+ return { ok: false, error: 'Import "rules" must be an array.' };
+ }
+ if (
+ settingsRaw.phoneticPairs !== undefined &&
+ !Array.isArray(settingsRaw.phoneticPairs)
+ ) {
+ return { ok: false, error: 'Import "phoneticPairs" must be an array.' };
+ }
+
+ const rules: TtsCleanupRule[] = [];
+ let skippedRules = 0;
+ for (const item of settingsRaw.rules ?? []) {
+ const rule = sanitizeImportedRule(item);
+ if (rule) {
+ rules.push(rule);
+ } else {
+ skippedRules += 1;
+ }
+ }
+
+ const phoneticPairs: TtsPhoneticPair[] = [];
+ for (const item of settingsRaw.phoneticPairs ?? []) {
+ const pair = sanitizeImportedPair(item);
+ if (pair) {
+ phoneticPairs.push(pair);
+ }
+ }
+
+ const settings: TtsTextCleanupSettings = {
+ enabled:
+ typeof settingsRaw.enabled === 'boolean'
+ ? settingsRaw.enabled
+ : !!current.enabled,
+ normalizeUnicode:
+ typeof settingsRaw.normalizeUnicode === 'boolean'
+ ? settingsRaw.normalizeUnicode
+ : !!current.normalizeUnicode,
+ rules,
+ phoneticPairs,
+ };
+
+ const skippedNote =
+ skippedRules > 0
+ ? ` (${skippedRules} invalid rule${skippedRules === 1 ? '' : 's'} skipped)`
+ : '';
+ return {
+ ok: true,
+ settings,
+ summary: `Imported ${rules.length} rule${rules.length === 1 ? '' : 's'}, ${phoneticPairs.length} phonetic pair${
+ phoneticPairs.length === 1 ? '' : 's'
+ }.${skippedNote}`,
+ };
+}
+
+function sanitizeImportedRule(item: unknown): TtsCleanupRule | null {
+ if (typeof item !== 'object' || item === null) {
+ return null;
+ }
+ const raw = item as Record;
+ if (typeof raw.pattern !== 'string' || raw.pattern.length === 0) {
+ return null;
+ }
+ const isRegex = raw.isRegex === true;
+ const replacement =
+ typeof raw.replacement === 'string' ? raw.replacement : '';
+ let flags = typeof raw.flags === 'string' ? raw.flags : 'g';
+ if (isRegex) {
+ if (raw.pattern.length > TTS_CLEANUP_MAX_REGEX_LENGTH) {
+ return null;
+ }
+ flags = normalizeRegExpFlags(flags);
+ try {
+ new RegExp(raw.pattern, flags);
+ } catch {
+ return null;
+ }
+ }
+ return createTtsCleanupRule(
+ raw.pattern,
+ replacement,
+ isRegex,
+ flags,
+ raw.enabled !== false,
+ );
+}
+
+function sanitizeImportedPair(item: unknown): TtsPhoneticPair | null {
+ if (typeof item !== 'object' || item === null) {
+ return null;
+ }
+ const raw = item as Record;
+ if (typeof raw.word !== 'string' || raw.word.length === 0) {
+ return null;
+ }
+ const pronunciation =
+ typeof raw.pronunciation === 'string' ? raw.pronunciation : '';
+ const matchMode = raw.matchMode === 'substring' ? 'substring' : 'whole-word';
+ return createTtsPhoneticPair(
+ raw.word,
+ pronunciation,
+ raw.enabled !== false,
+ matchMode,
+ );
+}
diff --git a/src/screens/settings/SettingsReaderScreen/tabs/AccessibilityTab.tsx b/src/screens/settings/SettingsReaderScreen/tabs/AccessibilityTab.tsx
index 2b2970e701..2e5c95ab72 100644
--- a/src/screens/settings/SettingsReaderScreen/tabs/AccessibilityTab.tsx
+++ b/src/screens/settings/SettingsReaderScreen/tabs/AccessibilityTab.tsx
@@ -23,6 +23,8 @@ import EnginePickerModal from '../Modals/EnginePickerModal';
import TTSScrollBehaviorModal from '../Modals/TTSScrollBehaviorModal';
import AutoResumeModal from '../Modals/AutoResumeModal';
+import TtsTextCleanupModal from '../Modals/TtsTextCleanupModal';
+import { DEFAULT_TTS_CLEANUP_SETTINGS } from '@utils/htmlParagraphExtractor';
interface TTSVoiceSettings {
identifier?: string;
@@ -60,6 +62,7 @@ const AccessibilityTab: React.FC = () => {
ttsAutoDownloadAmount = '10',
ttsForwardChapterReset = 'none',
ttsShowGestureHints = true,
+ ttsTextCleanup = DEFAULT_TTS_CLEANUP_SETTINGS,
setChapterGeneralSettings,
} = useChapterGeneralSettings();
@@ -155,6 +158,11 @@ const AccessibilityTab: React.FC = () => {
setTrue: showTtsResetModeModal,
setFalse: hideTtsResetModeModal,
} = useBoolean();
+ const {
+ value: ttsTextCleanupModalVisible,
+ setTrue: showTtsTextCleanupModal,
+ setFalse: hideTtsTextCleanupModal,
+ } = useBoolean();
const loadVoices = React.useCallback(() => {
TTSHighlight.getVoices().then(res => {
@@ -657,6 +665,28 @@ const AccessibilityTab: React.FC = () => {
/>
+ TTS Text Cleanup
+
+ setChapterGeneralSettings({
+ ttsTextCleanup: {
+ ...ttsTextCleanup,
+ enabled: !ttsTextCleanup.enabled,
+ },
+ })
+ }
+ theme={theme}
+ />
+ r.enabled).length} active rules · ${(ttsTextCleanup.phoneticPairs ?? []).filter(p => p.enabled).length} phonetic`}
+ onPress={showTtsTextCleanupModal}
+ theme={theme}
+ />
+
TTS Scroll Behavior
{
},
]}
/>
+
+ setChapterGeneralSettings({ ttsTextCleanup: nextSettings })
+ }
+ />
>
);
diff --git a/src/services/__tests__/NovelTtsSettings.test.ts b/src/services/__tests__/NovelTtsSettings.test.ts
index b5f52cd4af..5f7c7f4abf 100644
--- a/src/services/__tests__/NovelTtsSettings.test.ts
+++ b/src/services/__tests__/NovelTtsSettings.test.ts
@@ -2,6 +2,7 @@ import {
getNovelTtsSettings,
setNovelTtsSettings,
deleteNovelTtsSettings,
+ resolveEffectiveTtsCleanup,
} from '../tts/novelTtsSettings';
import { MMKVStorage } from '@utils/mmkv/mmkv';
import { VoiceQuality } from 'expo-speech';
@@ -210,4 +211,87 @@ describe('Per-Novel TTS Settings', () => {
expect(result?.tts?.pitch).toBe(0.7);
});
});
+
+ describe('resolveEffectiveTtsCleanup', () => {
+ const globalCleanup = {
+ enabled: true,
+ normalizeUnicode: false,
+ rules: [
+ {
+ id: 'r1',
+ enabled: true,
+ pattern: 'u2014',
+ isRegex: false,
+ flags: 'g',
+ replacement: ' ',
+ },
+ ],
+ phoneticPairs: [],
+ };
+ const novelCleanup = {
+ enabled: true,
+ normalizeUnicode: true,
+ rules: [],
+ phoneticPairs: [
+ { id: 'p1', enabled: true, word: 'Qing', pronunciation: 'Ching' },
+ ],
+ };
+
+ it('returns global cleanup when novelId is undefined', () => {
+ expect(resolveEffectiveTtsCleanup(globalCleanup, undefined)).toBe(
+ globalCleanup,
+ );
+ });
+
+ it('returns global cleanup when no per-novel settings exist', () => {
+ (MMKVStorage.getString as jest.Mock).mockReturnValue(undefined);
+ expect(resolveEffectiveTtsCleanup(globalCleanup, 123)).toBe(
+ globalCleanup,
+ );
+ });
+
+ it('returns global cleanup when per-novel mode is disabled', () => {
+ (MMKVStorage.getString as jest.Mock).mockReturnValue(
+ JSON.stringify({
+ enabled: false,
+ tts: {},
+ ttsTextCleanup: novelCleanup,
+ }),
+ );
+ expect(resolveEffectiveTtsCleanup(globalCleanup, 123)).toBe(
+ globalCleanup,
+ );
+ });
+
+ it('returns per-novel cleanup when enabled and saved', () => {
+ (MMKVStorage.getString as jest.Mock).mockReturnValue(
+ JSON.stringify({
+ enabled: true,
+ tts: {},
+ ttsTextCleanup: novelCleanup,
+ }),
+ );
+ expect(resolveEffectiveTtsCleanup(globalCleanup, 123)).toEqual(
+ novelCleanup,
+ );
+ });
+
+ it('returns global cleanup when enabled but no cleanup override saved', () => {
+ (MMKVStorage.getString as jest.Mock).mockReturnValue(
+ JSON.stringify({ enabled: true, tts: {} }),
+ );
+ expect(resolveEffectiveTtsCleanup(globalCleanup, 123)).toBe(
+ globalCleanup,
+ );
+ });
+
+ it('degrades to global cleanup when MMKV read throws', () => {
+ (MMKVStorage.getString as jest.Mock).mockImplementation(() => {
+ throw new Error('mmkv read failure');
+ });
+ expect(resolveEffectiveTtsCleanup(globalCleanup, 123)).toBe(
+ globalCleanup,
+ );
+ });
+ });
});
diff --git a/src/services/tts/novelTtsSettings.ts b/src/services/tts/novelTtsSettings.ts
index 909a5c9146..004f6803d4 100644
--- a/src/services/tts/novelTtsSettings.ts
+++ b/src/services/tts/novelTtsSettings.ts
@@ -2,6 +2,7 @@ import { Voice } from 'expo-speech';
import { getMMKVObject, setMMKVObject, MMKVStorage } from '@utils/mmkv/mmkv';
import { useMMKVObject } from 'react-native-mmkv';
+import { TtsTextCleanupSettings } from '@utils/htmlParagraphExtractor';
export type NovelTtsSettings = {
enabled: boolean;
@@ -11,6 +12,12 @@ export type NovelTtsSettings = {
pitch?: number;
engine?: string;
};
+ /**
+ * Per-novel TTS text cleanup. When present (and per-novel mode is
+ * enabled), this REPLACES the global cleanup for the novel. Absent for
+ * legacy stored objects, in which case the global cleanup applies.
+ */
+ ttsTextCleanup?: TtsTextCleanupSettings;
};
const keyForNovelTtsSettings = (novelId: number) =>
@@ -31,3 +38,26 @@ export const useNovelTtsSettings = (novelId?: number) => {
novelId ? keyForNovelTtsSettings(novelId) : 'DUMMY_KEY_NEVER_USED',
);
};
+
+/**
+ * Resolve the effective TTS text-cleanup settings for a novel: the
+ * per-novel override when per-novel TTS is enabled AND a cleanup override
+ * was saved, otherwise the global cleanup. Any MMKV read failure degrades
+ * to the global settings.
+ */
+export function resolveEffectiveTtsCleanup(
+ globalCleanup: TtsTextCleanupSettings | null | undefined,
+ novelId?: number,
+): TtsTextCleanupSettings | null | undefined {
+ if (typeof novelId === 'number') {
+ try {
+ const stored = getNovelTtsSettings(novelId);
+ if (stored?.enabled && stored.ttsTextCleanup) {
+ return stored.ttsTextCleanup;
+ }
+ } catch {
+ // Fall through to global on any MMKV read failure.
+ }
+ }
+ return globalCleanup;
+}
diff --git a/src/utils/__tests__/ttsTextCleanup.test.ts b/src/utils/__tests__/ttsTextCleanup.test.ts
new file mode 100644
index 0000000000..efdee068ca
--- /dev/null
+++ b/src/utils/__tests__/ttsTextCleanup.test.ts
@@ -0,0 +1,344 @@
+import {
+ cleanTtsText,
+ applyTtsTextCleanup,
+ createTtsCleanupRule,
+ createTtsPhoneticPair,
+ normalizeUnicodeText,
+ DEFAULT_TTS_CLEANUP_SETTINGS,
+ TtsTextCleanupSettings,
+ TTS_CLEANUP_MAX_REGEX_LENGTH,
+ isPotentiallyCatastrophic,
+ normalizeRegExpFlags,
+} from '../htmlParagraphExtractor';
+
+const buildSettings = (
+ overrides: Partial = {},
+): TtsTextCleanupSettings => ({
+ ...DEFAULT_TTS_CLEANUP_SETTINGS,
+ ...overrides,
+});
+
+describe('cleanTtsText', () => {
+ it('returns text unchanged when cleanup is disabled', () => {
+ const settings = buildSettings({ enabled: false });
+ expect(cleanTtsText('Some text with u2014 watermark', settings)).toBe(
+ 'Some text with u2014 watermark',
+ );
+ });
+
+ it('returns text unchanged when settings are undefined/null', () => {
+ expect(cleanTtsText('Hello', undefined)).toBe('Hello');
+ expect(cleanTtsText('Hello', null)).toBe('Hello');
+ });
+
+ it('returns empty text unchanged', () => {
+ const settings = buildSettings({ enabled: true });
+ expect(cleanTtsText('', settings)).toBe('');
+ });
+
+ it('returns text unchanged when there are no enabled rules', () => {
+ const settings = buildSettings({
+ enabled: true,
+ rules: [createTtsCleanupRule('a', 'b', false, 'g', false)],
+ });
+ expect(cleanTtsText('alpha', settings)).toBe('alpha');
+ });
+
+ it('applies a literal find/replace rule to all occurrences', () => {
+ const settings = buildSettings({
+ enabled: true,
+ rules: [createTtsCleanupRule('u2014', ' ')],
+ });
+ expect(cleanTtsText('The u2014 corrupted u2014 text', settings)).toBe(
+ 'The corrupted text',
+ );
+ });
+
+ it('applies a regex rule with flags', () => {
+ const settings = buildSettings({
+ enabled: true,
+ rules: [
+ createTtsCleanupRule('(Do not rehost this novel)+', '', true, 'gi'),
+ ],
+ });
+ expect(
+ cleanTtsText('do not rehost this novel This is the story.', settings),
+ ).toBe(' This is the story.');
+ });
+
+ it('strips matched spans with an empty replacement', () => {
+ const settings = buildSettings({
+ enabled: true,
+ rules: [createTtsCleanupRule('\\(Official version\\)\\s*', '', true)],
+ });
+ expect(
+ cleanTtsText(
+ 'Chapter 1 (Official version) The journey begins.',
+ settings,
+ ),
+ ).toBe('Chapter 1 The journey begins.');
+ });
+
+ it('handles the author-style watermark regex tolerant of spacing', () => {
+ const settings = buildSettings({
+ enabled: true,
+ rules: [
+ createTtsCleanupRule(
+ 'N(?:\\W)*o(?:\\W)*v(?:\\W)*e(?:\\W)*l(?:\\W)*i(?:\\W)*g(?:\\W)*h(?:\\W)*t',
+ '',
+ true,
+ 'gi',
+ ),
+ ],
+ });
+ expect(
+ cleanTtsText('Read on N o v e l i g h t for the full version', settings),
+ ).toBe('Read on for the full version');
+ });
+
+ it('skips invalid regex rules without throwing', () => {
+ const settings = buildSettings({
+ enabled: true,
+ rules: [
+ createTtsCleanupRule('([unclosed', 'x', true),
+ createTtsCleanupRule('valid', 'replaced', true),
+ ],
+ });
+ expect(cleanTtsText('valid text', settings)).toBe('replaced text');
+ });
+
+ it('normalizes unicode (NFD + strips combining marks)', () => {
+ expect(normalizeUnicodeText('cafe\u0301')).toBe('cafe');
+ const settings = buildSettings({ enabled: true, normalizeUnicode: true });
+ expect(cleanTtsText('Zoë went to café\u0301', settings)).toBe(
+ 'Zoe went to cafe',
+ );
+ });
+
+ it('maps unicode lookalike characters via user rules', () => {
+ const settings = buildSettings({
+ enabled: true,
+ normalizeUnicode: true,
+ rules: [
+ createTtsCleanupRule('\u{1D40D}', 'N'), // mathematical bold N
+ createTtsCleanupRule('\u{1D4DD}', 'N'), // mathematical script N
+ ],
+ });
+ expect(cleanTtsText('\u{1D40D}ovelight \u{1D4DD}ovel', settings)).toBe(
+ 'Novelight Novel',
+ );
+ });
+
+ it('applies phonetic whole-word swaps', () => {
+ const settings = buildSettings({
+ enabled: true,
+ phoneticPairs: [
+ createTtsPhoneticPair('Xianxia', 'Shee-an-shah'),
+ createTtsPhoneticPair('Qing', 'Ching'),
+ ],
+ });
+ expect(
+ cleanTtsText('He cultivates Xianxia in the Qing mountains', settings),
+ ).toBe('He cultivates Shee-an-shah in the Ching mountains');
+ });
+
+ it('does not swap phonetic words inside larger words', () => {
+ const settings = buildSettings({
+ enabled: true,
+ phoneticPairs: [createTtsPhoneticPair('Ainz', 'Ownz')],
+ });
+ // 'Ainz' at start, standalone, followed by punctuation, and inside a word
+ expect(cleanTtsText('Ainz Ooal Gown ainz, Ainzura', settings)).toBe(
+ 'Ownz Ooal Gown ainz, Ainzura',
+ );
+ });
+
+ it('substring match mode replaces adjacent CJK occurrences', () => {
+ const settings = buildSettings({
+ enabled: true,
+ phoneticPairs: [
+ createTtsPhoneticPair('秦', 'Qin', true, 'substring'),
+ createTtsPhoneticPair('卿', 'Qing', true, 'substring'),
+ ],
+ });
+ // Whole-word mode would no-op here (adjacent CJK are both \p{L});
+ // substring mode replaces every occurrence.
+ expect(cleanTtsText('秦国 大秦 卿卿', settings)).toBe(
+ 'Qin国 大Qin QingQing',
+ );
+ });
+
+ it('defaults to whole-word mode when matchMode is absent', () => {
+ const pair = createTtsPhoneticPair('秦', 'Qin');
+ expect(pair.matchMode).toBe('whole-word');
+ const settings = buildSettings({
+ enabled: true,
+ phoneticPairs: [pair],
+ });
+ // Legacy pair without matchMode behaves as before (no CJK adjacency hit)
+ expect(cleanTtsText('秦国', settings)).toBe('秦国');
+ });
+
+ it('applies rules before phonetic swaps (pipeline order)', () => {
+ const settings = buildSettings({
+ enabled: true,
+ rules: [createTtsCleanupRule('Mr. Qing', 'Qing')],
+ phoneticPairs: [createTtsPhoneticPair('Qing', 'Ching')],
+ });
+ expect(cleanTtsText('Mr. Qing arrived', settings)).toBe('Ching arrived');
+ });
+
+ it('supports regex flags on phonetic-independent rules only', () => {
+ const settings = buildSettings({
+ enabled: true,
+ rules: [createTtsCleanupRule('^\\[TTS\\]\\s*', '', true)],
+ });
+ expect(cleanTtsText('[TTS] Once upon a time', settings)).toBe(
+ 'Once upon a time',
+ );
+ });
+});
+
+describe('isPotentiallyCatastrophic', () => {
+ it('detects known exponential-backtracking shapes', () => {
+ expect(isPotentiallyCatastrophic('(a+)+')).toBe(true);
+ expect(isPotentiallyCatastrophic('(?:a*)*')).toBe(true);
+ expect(isPotentiallyCatastrophic('(?:a?)+')).toBe(true);
+ expect(isPotentiallyCatastrophic('(?:a+){2,}')).toBe(true);
+ expect(isPotentiallyCatastrophic('(a|a)+')).toBe(true);
+ expect(isPotentiallyCatastrophic('(?:x|x)+')).toBe(true);
+ expect(isPotentiallyCatastrophic('(?:a+){2,10}')).toBe(true);
+ });
+
+ it('does not flag common safe patterns', () => {
+ // The author's watermark fragment + full pattern from the issue thread
+ expect(isPotentiallyCatastrophic('(?:\\W)*')).toBe(false);
+ expect(
+ isPotentiallyCatastrophic(
+ 'N(?:\\W)*o(?:\\W)*v(?:\\W)*e(?:\\W)*l(?:\\W)*i(?:\\W)*g(?:\\W)*h(?:\\W)*t',
+ ),
+ ).toBe(false);
+ // Existing test patterns
+ expect(isPotentiallyCatastrophic('(Do not rehost this novel)+')).toBe(
+ false,
+ );
+ expect(isPotentiallyCatastrophic('\\(Official version\\)\\s*')).toBe(false);
+ expect(isPotentiallyCatastrophic('(?:ab)+')).toBe(false);
+ expect(isPotentiallyCatastrophic('[0-9]+')).toBe(false);
+ expect(isPotentiallyCatastrophic('^[a-z0-9_]+(?:[.-][a-z0-9_]+)*$')).toBe(
+ false,
+ );
+ expect(isPotentiallyCatastrophic('(?:\\W|\\d)+')).toBe(false);
+ expect(isPotentiallyCatastrophic('(?:ab){2,4}')).toBe(false);
+ expect(isPotentiallyCatastrophic('(?:x){2,}')).toBe(false);
+ expect(isPotentiallyCatastrophic('a+b+')).toBe(false);
+ });
+});
+
+describe('normalizeRegExpFlags', () => {
+ it('always includes g and dedupes duplicates', () => {
+ expect(normalizeRegExpFlags('')).toBe('g');
+ expect(normalizeRegExpFlags('gg')).toBe('g');
+ expect(normalizeRegExpFlags('gi')).toBe('gi');
+ expect(normalizeRegExpFlags('xgi')).toBe('gi');
+ });
+
+ it('drops the sticky y flag so mid-string matches work', () => {
+ expect(normalizeRegExpFlags('y')).toBe('g');
+ expect(normalizeRegExpFlags('iy')).toBe('ig');
+ });
+});
+
+describe('cleanTtsText hardening', () => {
+ it('skips regex rules exceeding the length cap', () => {
+ const longPattern = 'a'.repeat(TTS_CLEANUP_MAX_REGEX_LENGTH + 1);
+ const settings = buildSettings({
+ enabled: true,
+ rules: [
+ createTtsCleanupRule(longPattern, 'x', true),
+ createTtsCleanupRule('u2014', ' '),
+ ],
+ });
+ expect(cleanTtsText('hello u2014 world', settings)).toBe('hello world');
+ });
+
+ it('skips a catastrophic regex rule without breaking later rules', () => {
+ const settings = buildSettings({
+ enabled: true,
+ rules: [
+ createTtsCleanupRule('(a+)+', 'x', true), // skipped (unsafe)
+ createTtsCleanupRule('u2014', ' '), // still applied
+ ],
+ });
+ expect(cleanTtsText('aaaa u2014 text', settings)).toBe('aaaa text');
+ });
+
+ it('treats regex replacement text literally ($& stays literal)', () => {
+ const settings = buildSettings({
+ enabled: true,
+ rules: [createTtsCleanupRule('\\d+', '$&', true)],
+ });
+ expect(cleanTtsText('price 5', settings)).toBe('price $&');
+ });
+
+ it('sticky y flags behave like a global scan (mid-string match)', () => {
+ const settings = buildSettings({
+ enabled: true,
+ rules: [createTtsCleanupRule('world', 'replaced', true, 'y')],
+ });
+ expect(cleanTtsText('hello world', settings)).toBe('hello replaced');
+ });
+
+ it('matches precomposed literal patterns against normalized text', () => {
+ const settings = buildSettings({
+ enabled: true,
+ normalizeUnicode: true,
+ rules: [createTtsCleanupRule('é', 'x')],
+ });
+ // Text is NFD-normalized first (café -> cafe'), so the precomposed
+ // 'é' pattern must be normalized too before matching.
+ expect(cleanTtsText('café', settings)).toBe('cafx');
+ });
+
+ it('leaves text unchanged when a non-string reaches the pipeline', () => {
+ const settings = buildSettings({ enabled: true });
+ // Cast: not reachable from extractParagraphs, but a cheap runtime guard.
+ expect(cleanTtsText(42 as unknown as string, settings)).toBe(42);
+ });
+});
+
+describe('applyTtsTextCleanup', () => {
+ const settings = buildSettings({
+ enabled: true,
+ rules: [createTtsCleanupRule('u2014', ' ')],
+ });
+
+ it('cleans every paragraph', () => {
+ const paragraphs = ['a u2014 b', 'c u2014 d'];
+ expect(applyTtsTextCleanup(paragraphs, settings)).toEqual([
+ 'a b',
+ 'c d',
+ ]);
+ });
+
+ it('is length-preserving (never drops or merges paragraphs)', () => {
+ const paragraphs = ['u2014 only', '', 'text', 'u2014', 'last'];
+ const result = applyTtsTextCleanup(paragraphs, settings);
+ expect(result).toHaveLength(paragraphs.length);
+ });
+
+ it('returns the same array instance when disabled', () => {
+ const paragraphs = ['a', 'b'];
+ const disabled = buildSettings({ enabled: false });
+ expect(applyTtsTextCleanup(paragraphs, disabled)).toBe(paragraphs);
+ });
+
+ it('handles undefined/null input', () => {
+ expect(applyTtsTextCleanup(undefined, settings)).toBeUndefined();
+ expect(applyTtsTextCleanup(null, settings)).toBeNull();
+ });
+
+ it('returns empty array unchanged', () => {
+ expect(applyTtsTextCleanup([], settings)).toEqual([]);
+ });
+});
diff --git a/src/utils/htmlParagraphExtractor.ts b/src/utils/htmlParagraphExtractor.ts
index 9bd315c9c2..b8d0f299c9 100644
--- a/src/utils/htmlParagraphExtractor.ts
+++ b/src/utils/htmlParagraphExtractor.ts
@@ -167,3 +167,353 @@ export function extractParagraphsFrom(
return allParagraphs.slice(startIndex, endIndex);
}
+
+// =============================================================================
+// TTS Text Cleanup
+//
+// Declarative, length-preserving text cleanup applied to every paragraph that
+// reaches the native TTS engine, across ALL playback modes and paths:
+// 1. Initial queue (all modes) - RN extractParagraphs() output
+// 2. Foreground refill (Path B) - WebView DOM tts-queue payloads
+// 3. Fallback single-speak - WebView 'speak' payloads
+//
+// Design constraints (see PR for issue #17):
+// - No arbitrary user JS evaluation in the RN/Hermes layer (no eval/Function).
+// - Length-preserving: never drops or merges array entries, so the
+// RN <-> WebView paragraph index contract stays intact.
+// - No hardcoded site-specific regexes; everything is user-configurable.
+// =============================================================================
+
+/**
+ * A single find/replace cleanup rule.
+ * Can be a literal string replacement or a regex (pattern + flags).
+ */
+export interface TtsCleanupRule {
+ id: string;
+ enabled: boolean;
+ /** Find pattern: literal text or regex source (when isRegex is true). */
+ pattern: string;
+ /** True when pattern should be compiled as a RegExp. */
+ isRegex: boolean;
+ /** RegExp flags (e.g. 'g', 'gi'). Ignored for literal rules. Defaults to 'g'. */
+ flags: string;
+ /** Replacement text (empty string strips matched spans). */
+ replacement: string;
+}
+
+/**
+ * Phonetic pronunciation swap: exact whole-word replacements performed after
+ * rules so engines stop mispronouncing LN-specific names/honorifics
+ * (e.g. 'Xianxia' -> 'Shee-an-shah').
+ */
+export interface TtsPhoneticPair {
+ id: string;
+ enabled: boolean;
+ /** Word to look for (exact, whole-word, case-sensitive match). */
+ word: string;
+ /** What the TTS engine should say instead. */
+ pronunciation: string;
+ /**
+ * Optional; defaults to 'whole-word'. 'substring' replaces every
+ * occurrence, which is needed for unspaced CJK text where whole-word
+ * boundaries never fire between adjacent CJK characters.
+ */
+ matchMode?: 'whole-word' | 'substring';
+}
+
+/** Full user-configurable TTS text cleanup settings. */
+export interface TtsTextCleanupSettings {
+ /** Master switch: apply cleanup to TTS text. */
+ enabled: boolean;
+ /** NFD-normalize and strip combining marks before other rules. */
+ normalizeUnicode: boolean;
+ /** Ordered find/replace + strip rules. */
+ rules: TtsCleanupRule[];
+ /** Ordered phonetic pronunciation swaps. */
+ phoneticPairs: TtsPhoneticPair[];
+}
+
+export const DEFAULT_TTS_CLEANUP_SETTINGS: TtsTextCleanupSettings = {
+ enabled: false,
+ normalizeUnicode: false,
+ rules: [],
+ phoneticPairs: [],
+};
+
+/**
+ * Maximum length for a user-authored regex pattern. Bounds both the compile
+ * and per-paragraph scan cost. Real-world watermark/pronunciation patterns
+ * are far shorter (the issue thread's longest is < 80 chars).
+ */
+export const TTS_CLEANUP_MAX_REGEX_LENGTH = 200;
+
+const VALID_REGEX_FLAGS = ['d', 'g', 'i', 'm', 's', 'u', 'v', 'y'];
+
+// Narrow catastrophic-backtracking heuristics (defense-in-depth on top of the
+// length cap + compile-time try/catch). Kept intentionally narrow so common
+// safe patterns like `(?:[.-][a-z0-9_]+)*` or the issue's `(?:\\W)*` watermark
+// fragments are never rejected. Validated empirically against the existing
+// test corpus (see src/utils/__tests__/ttsTextCleanup.test.ts).
+const NESTED_SIMPLE_QUANT = /\((?:(?:\?:)?[^()[\]*+?{}][*+?])\)[*+?]/;
+const NESTED_SIMPLE_BOUNDED =
+ /\((?:(?:\?:)?[^()[\]*+?{}][*+?])\)\{[2-9]\d*(?:,\d*)?\}/;
+const IDENTICAL_ALT_QUANT = /\((?:(?:\?:)?([^()[\]*+?{}])\|(\1))\)[*+?]/;
+
+/**
+ * True when a regex source matches known exponential-backtracking shapes
+ * (e.g. `(a+)+`, `(?:a*)*`, `(?:a+){2,}`, `(a|a)+`). Best-effort structural
+ * detection: false negatives are acceptable (length cap + try/catch remain),
+ * false positives are not (a rejected rule silently stops cleaning).
+ */
+export function isPotentiallyCatastrophic(pattern: string): boolean {
+ return (
+ NESTED_SIMPLE_QUANT.test(pattern) ||
+ NESTED_SIMPLE_BOUNDED.test(pattern) ||
+ IDENTICAL_ALT_QUANT.test(pattern)
+ );
+}
+
+/**
+ * Normalize user-supplied regex flags: keep only valid flags, dedupe,
+ * always include `g` (cleanup is a global find/replace), and drop `y`
+ * (sticky without a global scan silently no-ops on mid-string matches).
+ */
+export function normalizeRegExpFlags(flags: string | undefined | null): string {
+ const seen = new Set();
+ for (const ch of flags ?? '') {
+ if (VALID_REGEX_FLAGS.includes(ch)) {
+ seen.add(ch);
+ }
+ }
+ seen.delete('y');
+ seen.add('g');
+ return [...seen].join('');
+}
+
+let ttsCleanupIdCounter = 0;
+
+function nextCleanupId(prefix: string): string {
+ ttsCleanupIdCounter += 1;
+ return `${prefix}-${Date.now().toString(36)}-${ttsCleanupIdCounter}`;
+}
+
+/** Factory helper for the settings UI. */
+export function createTtsCleanupRule(
+ pattern: string,
+ replacement = '',
+ isRegex = false,
+ flags = 'g',
+ enabled = true,
+): TtsCleanupRule {
+ return {
+ id: nextCleanupId('tts-rule'),
+ enabled,
+ pattern,
+ isRegex,
+ flags,
+ replacement,
+ };
+}
+
+/** Factory helper for the settings UI. */
+export function createTtsPhoneticPair(
+ word: string,
+ pronunciation = '',
+ enabled = true,
+ matchMode: 'whole-word' | 'substring' = 'whole-word',
+): TtsPhoneticPair {
+ return {
+ id: nextCleanupId('tts-phonetic'),
+ enabled,
+ word,
+ pronunciation,
+ matchMode,
+ };
+}
+
+/** Escape regex special characters for literal matching. */
+function escapeRegExp(text: string): string {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/** NFD-normalize and strip combining marks (e.g. 'e\u0301' -> 'e'). */
+const COMBINING_MARKS_REGEX = /[\u0300-\u036f]/g;
+
+export function normalizeUnicodeText(text: string): string {
+ return text.normalize('NFD').replace(COMBINING_MARKS_REGEX, '');
+}
+
+/**
+ * Apply a single cleanup rule; invalid or unsafe regexes are skipped silently.
+ * `normalizePattern` NFD-normalizes LITERAL patterns so they match text that
+ * was already normalized when `normalizeUnicode` is enabled (regex patterns
+ * are matched verbatim against already-normalized text by design).
+ */
+function applyCleanupRule(
+ text: string,
+ rule: TtsCleanupRule,
+ normalizePattern: boolean,
+): string {
+ if (!rule.pattern) {
+ return text;
+ }
+ if (rule.isRegex) {
+ if (
+ rule.pattern.length > TTS_CLEANUP_MAX_REGEX_LENGTH ||
+ isPotentiallyCatastrophic(rule.pattern)
+ ) {
+ return text;
+ }
+ try {
+ const flags = normalizeRegExpFlags(rule.flags);
+ // Callback form keeps the replacement LITERAL: with a string
+ // replacement, `$&`, `$'`, `$``, `$$` and `$n` are interpolated.
+ return text.replace(
+ new RegExp(rule.pattern, flags),
+ () => rule.replacement,
+ );
+ } catch {
+ // Invalid regex source: leave text untouched rather than crashing TTS.
+ return text;
+ }
+ }
+ const pattern = normalizePattern
+ ? normalizeUnicodeText(rule.pattern)
+ : rule.pattern;
+ if (!pattern) {
+ return text;
+ }
+ // Literal find/replace of all occurrences (fully literal — no `$` semantics).
+ return text.split(pattern).join(rule.replacement);
+}
+
+// Compiled whole-word regexes keyed by word (per-paragraph reuse across a
+// 2000-paragraph queue build). Bounded: cleared once it exceeds 500 entries.
+const wholeWordRegexCache = new Map();
+const WHOLE_WORD_CACHE_LIMIT = 500;
+
+function getWholeWordRegex(word: string): RegExp | null {
+ const cached = wholeWordRegexCache.get(word);
+ if (cached) {
+ return cached;
+ }
+ try {
+ const escaped = escapeRegExp(word);
+ const regex = new RegExp(
+ `(^|[^\\p{L}\\p{N}_])${escaped}(?![\\p{L}\\p{N}_])`,
+ 'gu',
+ );
+ if (wholeWordRegexCache.size >= WHOLE_WORD_CACHE_LIMIT) {
+ wholeWordRegexCache.clear();
+ }
+ wholeWordRegexCache.set(word, regex);
+ return regex;
+ } catch {
+ // Unicode property escapes unsupported on some runtime: degrade gracefully.
+ return null;
+ }
+}
+
+/**
+ * Replace an exact whole word (case-sensitive) without touching
+ * larger words that merely contain it. Unicode-aware boundaries so
+ * non-ASCII names match correctly. Returns text unchanged when the
+ * regex cannot be compiled.
+ */
+function replaceWholeWord(
+ text: string,
+ word: string,
+ replacement: string,
+): string {
+ if (!word) {
+ return text;
+ }
+ const regex = getWholeWordRegex(word);
+ if (!regex) {
+ return text;
+ }
+ regex.lastIndex = 0; // global regexes carry lastIndex between calls
+ return text.replace(regex, (_match: string, prefix: string) => {
+ return `${prefix}${replacement}`;
+ });
+}
+
+/**
+ * Clean a single text string using the configured cleanup settings.
+ * Returns the input unchanged when cleanup is disabled/empty.
+ *
+ * Pipeline order:
+ * 1. Unicode normalization (NFD + strip combining marks)
+ * 2. Ordered find/replace + strip rules
+ * 3. Phonetic dictionary (whole-word swaps)
+ */
+export function cleanTtsText(
+ text: string,
+ settings?: TtsTextCleanupSettings | null,
+): string {
+ if (typeof text !== 'string' || !text || !settings?.enabled) {
+ return text;
+ }
+
+ let result = text;
+
+ if (settings.normalizeUnicode) {
+ result = normalizeUnicodeText(result);
+ }
+
+ for (const rule of settings.rules ?? []) {
+ if (!rule.enabled) {
+ continue;
+ }
+ result = applyCleanupRule(result, rule, settings.normalizeUnicode);
+ }
+
+ for (const pair of settings.phoneticPairs ?? []) {
+ if (!pair.enabled || !pair.word) {
+ continue;
+ }
+ result =
+ pair.matchMode === 'substring'
+ ? result.split(pair.word).join(pair.pronunciation)
+ : replaceWholeWord(result, pair.word, pair.pronunciation);
+ }
+
+ return result;
+}
+
+/**
+ * Apply cleanup to a list of paragraphs. LENGTH-PRESERVING: the returned
+ * array always has the same length as the input (paragraph count drives the
+ * RN <-> WebView index contract and must never change).
+ */
+export function applyTtsTextCleanup(
+ paragraphs: string[],
+ settings?: TtsTextCleanupSettings | null,
+): string[];
+export function applyTtsTextCleanup(
+ paragraphs: string[] | undefined | null,
+ settings?: TtsTextCleanupSettings | null,
+): string[] | undefined | null;
+export function applyTtsTextCleanup(
+ paragraphs: string[] | undefined | null,
+ settings?: TtsTextCleanupSettings | null,
+): string[] | undefined | null {
+ if (
+ !settings?.enabled ||
+ !Array.isArray(paragraphs) ||
+ paragraphs.length === 0
+ ) {
+ return paragraphs;
+ }
+
+ const hasWork =
+ settings.normalizeUnicode ||
+ (settings.rules?.some(rule => rule.enabled) ?? false) ||
+ (settings.phoneticPairs?.some(pair => pair.enabled) ?? false);
+
+ if (!hasWork) {
+ return paragraphs;
+ }
+
+ return paragraphs.map(paragraph => cleanTtsText(paragraph, settings));
+}