feat(tts): replace words for TTS, closes #2057 (#2952)

This commit is contained in:
Huang Xin
2026-01-14 16:42:11 +01:00
committed by GitHub
parent 9c9c79176d
commit 7d97826e4b
36 changed files with 207 additions and 65 deletions
@@ -885,5 +885,6 @@
"Error": "خطأ",
"Unable to load the article. Try searching directly on {{link}}.": "تعذر تحميل المقال. حاول البحث مباشرة على {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "تعذر تحميل الكلمة. حاول البحث مباشرة على {{link}}.",
"Date Published": "تاريخ النشر"
"Date Published": "تاريخ النشر",
"Only for TTS:": "فقط لـ TTS:"
}
@@ -845,5 +845,6 @@
"Error": "ত্রুটি",
"Unable to load the article. Try searching directly on {{link}}.": "নিবন্ধ লোড করতে অক্ষম। সরাসরি {{link}} এ অনুসন্ধান করার চেষ্টা করুন।",
"Unable to load the word. Try searching directly on {{link}}.": "শব্দ লোড করতে অক্ষম। সরাসরি {{link}} এ অনুসন্ধান করার চেষ্টা করুন।",
"Date Published": "প্রকাশনার তারিখ"
"Date Published": "প্রকাশনার তারিখ",
"Only for TTS:": "শুধুমাত্র TTS-এর জন্য:"
}
@@ -835,5 +835,6 @@
"Error": "ནོར་འཁྲུལ།",
"Unable to load the article. Try searching directly on {{link}}.": "རྩོམ་ཡིག་མངོན་མི་ཐུབ། {{link}} ཐད་ཀར་འཚོལ་བ་བྱོས།",
"Unable to load the word. Try searching directly on {{link}}.": "ཚིག་མངོན་མི་ཐུབ། {{link}} ཐད་ཀར་འཚོལ་བ་བྱོས།",
"Date Published": "པར་སྐྲུན་ཚེས་གྲངས།"
"Date Published": "པར་སྐྲུན་ཚེས་གྲངས།",
"Only for TTS:": "TTS ལ་ཁོ་ན།:"
}
@@ -845,5 +845,6 @@
"Error": "Fehler",
"Unable to load the article. Try searching directly on {{link}}.": "Artikel kann nicht geladen werden. Versuchen Sie die direkte Suche auf {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Wort kann nicht geladen werden. Versuchen Sie die direkte Suche auf {{link}}.",
"Date Published": "Veröffentlichungsdatum"
"Date Published": "Veröffentlichungsdatum",
"Only for TTS:": "Nur für TTS:"
}
@@ -845,5 +845,6 @@
"Error": "Σφάλμα",
"Unable to load the article. Try searching directly on {{link}}.": "Αδυναμία φόρτωσης του άρθρου. Δοκιμάστε να αναζητήσετε απευθείας στο {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Αδυναμία φόρτωσης της λέξης. Δοκιμάστε να αναζητήσετε απευθείας στο {{link}}.",
"Date Published": "Ημερομηνία δημοσίευσης"
"Date Published": "Ημερομηνία δημοσίευσης",
"Only for TTS:": "Μόνο για TTS:"
}
@@ -855,5 +855,6 @@
"Error": "Error",
"Unable to load the article. Try searching directly on {{link}}.": "No se puede cargar el artículo. Intenta buscar directamente en {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "No se puede cargar la palabra. Intenta buscar directamente en {{link}}.",
"Date Published": "Fecha de publicación"
"Date Published": "Fecha de publicación",
"Only for TTS:": "Solo para TTS:"
}
@@ -845,5 +845,6 @@
"Error": "خطا",
"Unable to load the article. Try searching directly on {{link}}.": "بارگذاری مقاله امکان‌پذیر نیست. سعی کنید مستقیماً در {{link}} جستجو کنید.",
"Unable to load the word. Try searching directly on {{link}}.": "بارگذاری کلمه امکان‌پذیر نیست. سعی کنید مستقیماً در {{link}} جستجو کنید.",
"Date Published": "تاریخ انتشار"
"Date Published": "تاریخ انتشار",
"Only for TTS:": "فقط برای TTS:"
}
@@ -855,5 +855,6 @@
"Error": "Erreur",
"Unable to load the article. Try searching directly on {{link}}.": "Impossible de charger l'article. Essayez de rechercher directement sur {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Impossible de charger le mot. Essayez de rechercher directement sur {{link}}.",
"Date Published": "Date de publication"
"Date Published": "Date de publication",
"Only for TTS:": "Uniquement pour TTS :"
}
@@ -845,5 +845,6 @@
"Error": "त्रुटि",
"Unable to load the article. Try searching directly on {{link}}.": "लेख लोड करने में असमर्थ। सीधे {{link}} पर खोजने का प्रयास करें।",
"Unable to load the word. Try searching directly on {{link}}.": "शब्द लोड करने में असमर्थ। सीधे {{link}} पर खोजने का प्रयास करें।",
"Date Published": "प्रकाशन तिथि"
"Date Published": "प्रकाशन तिथि",
"Only for TTS:": "केवल TTS के लिए:"
}
@@ -835,5 +835,6 @@
"Error": "Kesalahan",
"Unable to load the article. Try searching directly on {{link}}.": "Tidak dapat memuat artikel. Coba cari langsung di {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Tidak dapat memuat kata. Coba cari langsung di {{link}}.",
"Date Published": "Tanggal terbit"
"Date Published": "Tanggal terbit",
"Only for TTS:": "Hanya untuk TTS:"
}
@@ -855,5 +855,6 @@
"Error": "Errore",
"Unable to load the article. Try searching directly on {{link}}.": "Impossibile caricare l'articolo. Prova a cercare direttamente su {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Impossibile caricare la parola. Prova a cercare direttamente su {{link}}.",
"Date Published": "Data di pubblicazione"
"Date Published": "Data di pubblicazione",
"Only for TTS:": "Solo per TTS:"
}
@@ -835,5 +835,6 @@
"Error": "エラー",
"Unable to load the article. Try searching directly on {{link}}.": "記事を読み込めません。{{link}}で直接検索してみてください。",
"Unable to load the word. Try searching directly on {{link}}.": "単語を読み込めません。{{link}}で直接検索してみてください。",
"Date Published": "出版日"
"Date Published": "出版日",
"Only for TTS:": "TTSのみ:"
}
@@ -835,5 +835,6 @@
"Error": "오류",
"Unable to load the article. Try searching directly on {{link}}.": "문서를 로드할 수 없습니다. {{link}}에서 직접 검색해 보세요.",
"Unable to load the word. Try searching directly on {{link}}.": "단어를 로드할 수 없습니다. {{link}}에서 직접 검색해 보세요.",
"Date Published": "출판일"
"Date Published": "출판일",
"Only for TTS:": "TTS 전용:"
}
@@ -835,5 +835,6 @@
"Error": "Ralat",
"Unable to load the article. Try searching directly on {{link}}.": "Tidak dapat memuatkan artikel. Cuba cari terus di {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Tidak dapat memuatkan perkataan. Cuba cari terus di {{link}}.",
"Date Published": "Tarikh diterbitkan"
"Date Published": "Tarikh diterbitkan",
"Only for TTS:": "Hanya untuk TTS:"
}
@@ -845,5 +845,6 @@
"Error": "Fout",
"Unable to load the article. Try searching directly on {{link}}.": "Kan het artikel niet laden. Probeer direct te zoeken op {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Kan het woord niet laden. Probeer direct te zoeken op {{link}}.",
"Date Published": "Publicatiedatum"
"Date Published": "Publicatiedatum",
"Only for TTS:": "Alleen voor TTS:"
}
@@ -865,5 +865,6 @@
"Error": "Błąd",
"Unable to load the article. Try searching directly on {{link}}.": "Nie można załadować artykułu. Spróbuj wyszukać bezpośrednio na {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Nie można załadować słowa. Spróbuj wyszukać bezpośrednio na {{link}}.",
"Date Published": "Data wydania"
"Date Published": "Data wydania",
"Only for TTS:": "Tylko dla TTS:"
}
@@ -855,5 +855,6 @@
"Error": "Erro",
"Unable to load the article. Try searching directly on {{link}}.": "Não foi possível carregar o artigo. Tente pesquisar diretamente em {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Não foi possível carregar a palavra. Tente pesquisar diretamente em {{link}}.",
"Date Published": "Data de publicação"
"Date Published": "Data de publicação",
"Only for TTS:": "Apenas para TTS:"
}
@@ -865,5 +865,6 @@
"Error": "Ошибка",
"Unable to load the article. Try searching directly on {{link}}.": "Не удалось загрузить статью. Попробуйте искать напрямую на {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Не удалось загрузить слово. Попробуйте искать напрямую на {{link}}.",
"Date Published": "Дата публикации"
"Date Published": "Дата публикации",
"Only for TTS:": "Только для TTS:"
}
@@ -845,5 +845,6 @@
"Error": "දෝෂය",
"Unable to load the article. Try searching directly on {{link}}.": "ලිපිය පූරණය කළ නොහැක. {{link}} හි කෙලින්ම සෙවීමට උත්සාහ කරන්න.",
"Unable to load the word. Try searching directly on {{link}}.": "වචනය පූරණය කළ නොහැක. {{link}} හි කෙලින්ම සෙවීමට උත්සාහ කරන්න.",
"Date Published": "ප්‍රකාශන දිනය"
"Date Published": "ප්‍රකාශන දිනය",
"Only for TTS:": "TTS සඳහා පමණක්:"
}
@@ -845,5 +845,6 @@
"Error": "Fel",
"Unable to load the article. Try searching directly on {{link}}.": "Kan inte ladda artikeln. Försök söka direkt på {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Kan inte ladda ordet. Försök söka direkt på {{link}}.",
"Date Published": "Publiceringsdatum"
"Date Published": "Publiceringsdatum",
"Only for TTS:": "Endast för TTS:"
}
@@ -845,5 +845,6 @@
"Error": "பிழை",
"Unable to load the article. Try searching directly on {{link}}.": "கட்டுரையை ஏற்ற முடியவில்லை. நேரடியாக {{link}} இல் தேடவும்.",
"Unable to load the word. Try searching directly on {{link}}.": "சொல்லை ஏற்ற முடியவில்லை. நேரடியாக {{link}} இல் தேடவும்.",
"Date Published": "வெளியீட்டு தேதி"
"Date Published": "வெளியீட்டு தேதி",
"Only for TTS:": "TTS க்கு மட்டும்:"
}
@@ -835,5 +835,6 @@
"Error": "ข้อผิดพลาด",
"Unable to load the article. Try searching directly on {{link}}.": "ไม่สามารถโหลดบทความได้ ลองค้นหาโดยตรงที่ {{link}}",
"Unable to load the word. Try searching directly on {{link}}.": "ไม่สามารถโหลดคำได้ ลองค้นหาโดยตรงที่ {{link}}",
"Date Published": "วันที่เผยแพร่"
"Date Published": "วันที่เผยแพร่",
"Only for TTS:": "สำหรับ TTS เท่านั้น:"
}
@@ -845,5 +845,6 @@
"Error": "Hata",
"Unable to load the article. Try searching directly on {{link}}.": "Makale yüklenemiyor. Doğrudan {{link}} üzerinde arama yapmayı deneyin.",
"Unable to load the word. Try searching directly on {{link}}.": "Kelime yüklenemiyor. Doğrudan {{link}} üzerinde arama yapmayı deneyin.",
"Date Published": "Yayın tarihi"
"Date Published": "Yayın tarihi",
"Only for TTS:": "Sadece TTS için:"
}
@@ -865,5 +865,6 @@
"Error": "Помилка",
"Unable to load the article. Try searching directly on {{link}}.": "Не вдалося завантажити статтю. Спробуйте шукати безпосередньо на {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Не вдалося завантажити слово. Спробуйте шукати безпосередньо на {{link}}.",
"Date Published": "Дата публікації"
"Date Published": "Дата публікації",
"Only for TTS:": "Тільки для TTS:"
}
@@ -835,5 +835,6 @@
"Error": "Lỗi",
"Unable to load the article. Try searching directly on {{link}}.": "Không thể tải bài viết. Hãy thử tìm kiếm trực tiếp trên {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Không thể tải từ. Hãy thử tìm kiếm trực tiếp trên {{link}}.",
"Date Published": "Ngày xuất bản"
"Date Published": "Ngày xuất bản",
"Only for TTS:": "Chỉ dành cho TTS:"
}
@@ -832,6 +832,7 @@
"Failed to export the book.": "书籍导出失败。",
"Export Book": "导出书籍",
"Whole word:": "全词匹配:",
"Only for TTS:": "仅用于TTS",
"Error": "错误",
"Unable to load the article. Try searching directly on {{link}}.": "无法加载文章。请直接在 {{link}} 上搜索。",
"Unable to load the word. Try searching directly on {{link}}.": "无法加载词汇。请直接在 {{link}} 上搜索。",
@@ -835,5 +835,6 @@
"Error": "錯誤",
"Unable to load the article. Try searching directly on {{link}}.": "無法載入文章。請直接在 {{link}} 上搜尋。",
"Unable to load the word. Try searching directly on {{link}}.": "無法載入詞彙。請直接在 {{link}} 上搜尋。",
"Date Published": "出版日期"
"Date Published": "出版日期",
"Only for TTS:": "僅用於TTS"
}
@@ -402,11 +402,11 @@ describe('ProofreadRulesManager', () => {
const csRuleElement = screen.getByText('case-sensitive').closest('li');
expect(within(csRuleElement!).getByText(/Case sensitive:/)).toBeTruthy();
expect(within(csRuleElement!).getByText(/Yes/)).toBeTruthy();
expect(within(csRuleElement!).getAllByText(/Yes/)).toBeTruthy();
const ciRuleElement = screen.getByText('case-insensitive').closest('li');
expect(within(ciRuleElement!).getByText(/Case sensitive:/)).toBeTruthy();
expect(within(ciRuleElement!).getByText(/No/)).toBeTruthy();
expect(within(ciRuleElement!).getAllByText(/No/)).toBeTruthy();
});
it('opens when BookMenu item is clicked (integration)', async () => {
@@ -86,14 +86,14 @@ const RuleItem: React.FC<{
}
return (
<div className='flex items-start justify-between gap-3 p-3'>
<div className='relative flex items-start justify-between gap-3 p-3'>
<div className='flex min-w-0 flex-1 flex-col gap-1.5'>
<div className='break-words text-base font-medium leading-snug'>{rule.pattern}</div>
<div className='text-base-content/70 break-words text-sm'>
<span className='text-base-content/80 mr-1.5 text-xs font-medium'>
{_('Replace with:')}
</span>
<span className='text-base-content/90'>{rule.replacement}</span>
<span className='text-base-content/90 text-xs'>{rule.replacement}</span>
</div>
<div className='text-base-content/60 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs'>
<span className='inline-flex items-center gap-1'>
@@ -116,9 +116,16 @@ const RuleItem: React.FC<{
{rule.caseSensitive !== false ? _('Yes') : _('No')}
</span>
</span>
<span className='text-base-content/30'></span>
<span className='inline-flex items-center gap-1'>
<span className='text-base-content/50'>{_('Only for TTS:')}</span>
<span className='text-base-content/70 font-medium'>
{rule.onlyForTTS === true ? _('Yes') : _('No')}
</span>
</span>
</div>
</div>
<div className='flex shrink-0 items-center gap-1'>
<div className='absolute right-2 top-2 flex items-center gap-1'>
<button
className='btn btn-ghost btn-sm h-8 w-8 p-0'
onClick={onEdit}
@@ -187,7 +194,8 @@ export const ProofreadRulesManager: React.FC = () => {
pattern: string;
replacement: string;
enabled: boolean;
}>({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
onlyForTTS: boolean;
}>({ id: null, scope: null, pattern: '', replacement: '', enabled: true, onlyForTTS: false });
const { singleRules, bookRules } = useReplacementRules(sideBarBookKey);
@@ -199,18 +207,26 @@ export const ProofreadRulesManager: React.FC = () => {
el?.removeEventListener('setProofreadRulesVisibility', handleVisibility as EventListener);
}, []);
const startEdit = (rule: ProofreadRule, scope: ProofreadScope) => {
const startEdit = (rule: ProofreadRule) => {
setEditing({
id: rule.id,
scope,
scope: rule.scope,
pattern: rule.pattern,
replacement: rule.replacement,
enabled: !!rule.enabled,
onlyForTTS: !!rule.onlyForTTS,
});
};
const cancelEdit = () => {
setEditing({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
setEditing({
id: null,
scope: null,
pattern: '',
replacement: '',
enabled: true,
onlyForTTS: false,
});
};
const saveEdit = async () => {
@@ -221,16 +237,22 @@ export const ProofreadRulesManager: React.FC = () => {
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
onlyForTTS: editing.onlyForTTS,
});
cancelEdit();
recreateViewer(envConfig, sideBarBookKey);
if (!editing.onlyForTTS) {
recreateViewer(envConfig, sideBarBookKey);
}
};
const deleteRule = async (ruleId: string, scope: ProofreadScope) => {
const deleteRule = async (rule: ProofreadRule) => {
if (!sideBarBookKey) return;
await removeRule(envConfig, sideBarBookKey, ruleId, scope);
recreateViewer(envConfig, sideBarBookKey);
await removeRule(envConfig, sideBarBookKey, rule.id, rule.scope);
if (!rule.onlyForTTS) {
recreateViewer(envConfig, sideBarBookKey);
}
};
const renderRuleList = (
@@ -260,10 +282,8 @@ export const ProofreadRulesManager: React.FC = () => {
editing.scope === (scopeType === 'selection' ? 'selection' : rule.scope)
}
editingData={editing}
onEdit={() => startEdit(rule, scopeType === 'selection' ? 'selection' : rule.scope)}
onDelete={() =>
deleteRule(rule.id, scopeType === 'selection' ? 'selection' : rule.scope)
}
onEdit={() => startEdit(rule)}
onDelete={() => deleteRule(rule)}
onSave={saveEdit}
onCancel={cancelEdit}
onEditChange={(_, value) => setEditing({ ...editing, replacement: value })}
@@ -43,6 +43,7 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
const [caseSensitive, setCaseSensitive] = useState(true);
const [wholeWord, setWholeWord] = useState(!isPunctuationOnly(selection?.text || ''));
const [scope, setScope] = useState<ProofreadScope>('selection');
const [onlyForTTS, setOnlyForTTS] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useAutoFocus<HTMLInputElement>({ ref: inputRef });
@@ -89,6 +90,7 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
enabled: true,
caseSensitive,
wholeWord: wholeWord,
onlyForTTS: scope !== 'selection' ? onlyForTTS : undefined,
};
onConfirm?.(options);
@@ -96,7 +98,7 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
onDismiss();
if (scope !== 'selection') {
if (scope !== 'selection' && !onlyForTTS) {
if (getView(bookKey)) {
recreateViewer(envConfig, bookKey);
}
@@ -162,7 +164,7 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
</div>
</div>
<div className='flex items-center gap-8 p-4'>
<div className='flex flex-wrap items-center gap-4 p-4'>
<label className='flex cursor-pointer items-center gap-2'>
<span className='line-clamp-1 text-xs' title={_('Case sensitive:')}>
{_('Case sensitive:')}
@@ -196,6 +198,24 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
onChange={(e) => setWholeWord(e.target.checked)}
/>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<span className='line-clamp-1 text-xs' title={_('Only for TTS:')}>
{_('Only for TTS:')}
</span>
<input
type='checkbox'
disabled={scope === 'selection'}
className='toggle toggle-sm bg-gray-500 checked:bg-black hover:bg-gray-500 hover:checked:bg-black'
style={
{
'--tglbg': '#4B5563',
} as React.CSSProperties
}
checked={onlyForTTS}
onChange={(e) => setOnlyForTTS(e.target.checked)}
/>
</label>
</div>
<div className='flex flex-1 items-center justify-between gap-2 p-4'>
<label htmlFor='scope-select' className='line-clamp-1 text-xs' title={_('Scope:')}>
@@ -1,14 +1,17 @@
import clsx from 'clsx';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useProofreadStore } from '@/store/proofreadStore';
import { TransformContext } from '@/services/transformers/types';
import { proofreadTransformer } from '@/services/transformers/proofread';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { TTSController, SILENCE_DATA, TTSMark } from '@/services/tts';
import { TTSController, SILENCE_DATA, TTSMark, TTSHighlightOptions } from '@/services/tts';
import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
@@ -38,11 +41,12 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { user } = useAuth();
const { safeAreaInsets } = useThemeStore();
const { safeAreaInsets, isDarkMode } = useThemeStore();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const { hoveredBookKey, getView, getProgress, getViewSettings } = useReaderStore();
const { setViewSettings, setTTSEnabled } = useReaderStore();
const { getMergedRules } = useProofreadStore();
const viewSettings = getViewSettings(bookKey);
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
@@ -285,6 +289,61 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
ttsControllerRef.current?.setTargetLang(getTTSTargetLang() || '');
}, [getTTSTargetLang]);
const transformCtx: TransformContext = useMemo(
() => ({
bookKey,
viewSettings: getViewSettings(bookKey)!,
userLocale: getLocale(),
content: '',
transformers: [],
reversePunctuationTransform: true,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const preprocessSSMLForTTS = useCallback(
async (ssml: string) => {
const rules = getMergedRules(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const ttsOnlyRules = rules.filter(
(rule) =>
rule.enabled && rule.onlyForTTS && (rule.scope === 'book' || rule.scope === 'library'),
);
if (ttsOnlyRules.length === 0) return ssml;
transformCtx['content'] = ssml;
transformCtx['viewSettings'] = viewSettings;
ssml = await proofreadTransformer.transform(transformCtx, {
docType: 'text/xml',
onlyForTTS: true,
});
return ssml;
},
[bookKey, getMergedRules, getViewSettings, transformCtx],
);
const getTTSHighlightOptions = useCallback(
(ttsHighlightOptions: TTSHighlightOptions, isEink: boolean) => {
const einkBgColor = isDarkMode ? '#000000' : '#ffffff';
const color = isEink ? einkBgColor : ttsHighlightOptions.color;
return {
...ttsHighlightOptions,
color,
};
},
[isDarkMode],
);
useEffect(() => {
const ttsHighlightOptions = viewSettings?.ttsHighlightOptions;
if (ttsControllerRef.current && ttsHighlightOptions) {
ttsControllerRef.current.initViewTTS(
getTTSHighlightOptions(ttsHighlightOptions, viewSettings.isEink),
);
}
}, [viewSettings?.ttsHighlightOptions, viewSettings?.isEink, getTTSHighlightOptions]);
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey: ttsBookKey, range, oneTime = false } = event.detail;
if (bookKey !== ttsBookKey) return;
@@ -338,12 +397,14 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
if (!oneTime) {
setShowIndicator(true);
}
const ttsController = new TTSController(appService, view, !!user?.id);
const ttsController = new TTSController(appService, view, !!user?.id, preprocessSSMLForTTS);
ttsControllerRef.current = ttsController;
setTtsController(ttsController);
await ttsController.init();
await ttsController.initViewTTS(viewSettings.ttsHighlightOptions);
await ttsController.initViewTTS(
getTTSHighlightOptions(viewSettings.ttsHighlightOptions, viewSettings.isEink),
);
const ssml = view.tts?.from(ttsFromRange);
if (ssml) {
const lang = parseSSMLLang(ssml, primaryLang) || 'en';
@@ -643,13 +704,6 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
};
}, [hoveredBookKey]);
useEffect(() => {
const ttsHighlightOptions = viewSettings?.ttsHighlightOptions;
if (ttsControllerRef.current && ttsHighlightOptions) {
ttsControllerRef.current.initViewTTS(ttsHighlightOptions);
}
}, [viewSettings?.ttsHighlightOptions]);
return (
<>
{showPanel && <Overlay onDismiss={handleDismissPopup} />}
@@ -205,7 +205,12 @@ function getTextNodes(doc: Document): Text[] {
export const proofreadTransformer: Transformer = {
name: 'proofread',
transform: async (ctx) => {
transform: async (ctx, options) => {
const { docType = 'text/html', onlyForTTS = false } =
(options as {
docType?: DOMParserSupportedType;
onlyForTTS?: boolean;
}) || {};
const globalRules = useSettingsStore.getState().settings?.globalViewSettings?.proofreadRules;
const bookRules = ctx.viewSettings.proofreadRules;
const merged = [...(globalRules ?? []), ...(bookRules ?? [])].sort(
@@ -215,6 +220,7 @@ export const proofreadTransformer: Transformer = {
const processed = merged
.filter((r) => r.enabled && r.pattern.trim())
.filter((r) => (onlyForTTS ? r.onlyForTTS : !r.onlyForTTS))
.map((r) => ({
...r,
normalizedPattern: normalizePattern(r.pattern, r.isRegex, r.caseSensitive !== false),
@@ -223,7 +229,7 @@ export const proofreadTransformer: Transformer = {
if (!processed.length) return ctx.content;
const parser = new DOMParser();
const doc = parser.parseFromString(ctx.content, 'text/html');
const doc = parser.parseFromString(ctx.content, docType);
const textNodes = getTextNodes(doc);
const byScope = {
@@ -15,5 +15,5 @@ export type TransformContext = {
export type Transformer = {
name: string;
transform: (ctx: TransformContext) => Promise<string>;
transform: (ctx: TransformContext, options?: unknown) => Promise<string>;
};
@@ -26,6 +26,7 @@ export class TTSController extends EventTarget {
appService: AppService | null = null;
view: FoliateView;
isAuthenticated: boolean = false;
preprocessCallback?: (ssml: string) => Promise<string>;
#nossmlCnt: number = 0;
#currentSpeakAbortController: AbortController | null = null;
#currentSpeakPromise: Promise<void> | null = null;
@@ -44,7 +45,12 @@ export class TTSController extends EventTarget {
options: TTSHighlightOptions = { style: 'highlight', color: 'gray' };
constructor(appService: AppService | null, view: FoliateView, isAuthenticated: boolean = false) {
constructor(
appService: AppService | null,
view: FoliateView,
isAuthenticated: boolean = false,
preprocessCallback?: (ssml: string) => Promise<string>,
) {
super();
this.ttsWebClient = new WebSpeechClient(this);
this.ttsEdgeClient = new EdgeTTSClient(this);
@@ -56,6 +62,7 @@ export class TTSController extends EventTarget {
this.appService = appService;
this.view = view;
this.isAuthenticated = isAuthenticated;
this.preprocessCallback = preprocessCallback;
}
async init() {
@@ -112,7 +119,7 @@ export class TTSController extends EventTarget {
granularity,
createRejectFilter({
tags: ['rt'],
contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/}],
contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }],
}),
this.#getHighlighter(),
);
@@ -129,7 +136,7 @@ export class TTSController extends EventTarget {
if (!tts) return;
let preloaded = 0;
for (let i = 0; i < count; i++) {
const ssml = this.#preprocessSSML(tts.next());
const ssml = await this.#preprocessSSML(tts.next());
this.preloadSSML(ssml, new AbortController().signal);
if (ssml) preloaded++;
}
@@ -138,7 +145,7 @@ export class TTSController extends EventTarget {
}
}
#preprocessSSML(ssml?: string) {
async #preprocessSSML(ssml?: string) {
if (!ssml) return;
ssml = ssml
.replace(/<emphasis[^>]*>([^<]+)<\/emphasis>/g, '$1')
@@ -152,6 +159,11 @@ export class TTSController extends EventTarget {
if (this.ttsTargetLang) {
ssml = filterSSMLWithLang(ssml, this.ttsTargetLang);
}
if (this.preprocessCallback) {
ssml = await this.preprocessCallback(ssml);
}
return ssml;
}
@@ -169,7 +181,7 @@ export class TTSController extends EventTarget {
resolve();
});
ssml = this.#preprocessSSML(await ssml);
ssml = await this.#preprocessSSML(await ssml);
if (!ssml) {
this.#nossmlCnt++;
// FIXME: in case we are at the end of the book, need a better way to handle this
@@ -18,6 +18,7 @@ export interface CreateProofreadRuleOptions {
caseSensitive?: boolean;
order?: number;
wholeWord?: boolean;
onlyForTTS?: boolean;
}
interface ProofreadStoreState {
@@ -58,6 +59,7 @@ function createProofreadRule(opts: CreateProofreadRuleOptions): ProofreadRule {
caseSensitive: opts.caseSensitive ?? true,
order: opts.order ?? 1000,
wholeWord: opts.wholeWord ?? true,
onlyForTTS: opts.onlyForTTS ?? false,
};
}
+1
View File
@@ -242,6 +242,7 @@ export interface ProofreadRule {
order: number; // Lower numbers apply first
wholeWord?: boolean; // Match whole words only (uses \b word boundaries)
caseSensitive?: boolean; // Case-sensitive matching (default true)
onlyForTTS?: boolean; // Only replace text for TTS, not in the book display (only for book/library scope)
}
export interface ProofreadRulesConfig {