diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index 30eb86b..908b0ab 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -9,6 +9,12 @@
+
+
+
+
+
+
= AndroidReadAloudEngine.state
+ actual val settingsState: StateFlow = AndroidReadAloudEngine.settingsState
actual fun prepare(bookTitle: String, sentences: List, startIndex: Int) {
val context = androidAppContext() ?: return
@@ -49,6 +51,21 @@ actual object ReadAloudPlatform {
actual fun skip(delta: Int) {
AndroidReadAloudEngine.skip(delta)
}
+
+ actual fun refreshSettings() {
+ val context = androidAppContext() ?: return
+ AndroidReadAloudEngine.refreshSettings(context)
+ }
+
+ actual fun selectEngine(engineId: String?) {
+ val context = androidAppContext() ?: return
+ AndroidReadAloudEngine.selectEngine(context, engineId)
+ }
+
+ actual fun selectVoice(voiceId: String?) {
+ val context = androidAppContext() ?: return
+ AndroidReadAloudEngine.selectVoice(context, voiceId)
+ }
}
private object AndroidReadAloudEngine {
@@ -56,12 +73,22 @@ private object AndroidReadAloudEngine {
private const val BeforePausePrefix = "read-aloud-before-"
private const val SpeakPrefix = "read-aloud-speak-"
private const val AfterPausePrefix = "read-aloud-after-"
+ private const val SettingsPrefs = "read_aloud_tts"
+ private const val SelectedEngineKey = "selected_engine"
+ private const val EngineChoiceMadeKey = "engine_choice_made"
+ private const val SelectedVoiceKey = "selected_voice"
+ private val RussianLocale = Locale.forLanguageTag("ru-RU")
private val mutableState = MutableStateFlow(ReadAloudState())
val state: StateFlow = mutableState
+ private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState())
+ val settingsState: StateFlow = mutableSettingsState
private var tts: TextToSpeech? = null
private var ttsReady = false
+ private var ttsEngineId: String? = null
+ private var engineProbe: TextToSpeech? = null
+ private var voiceProbe: TextToSpeech? = null
private var shouldSpeakWhenReady = false
private var bookTitle: String = ""
private var sentences: List = emptyList()
@@ -110,34 +137,122 @@ private object AndroidReadAloudEngine {
}
}
- private fun ensureTts(context: Context) {
- if (tts != null) return
- tts = TextToSpeech(context) { status ->
- ttsReady = status == TextToSpeech.SUCCESS
- if (ttsReady) {
- tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
- override fun onStart(utteranceId: String?) = Unit
-
- override fun onDone(utteranceId: String?) {
- handleUtteranceDone(utteranceId)
- }
-
- @Deprecated("Deprecated in Java")
- override fun onError(utteranceId: String?) {
- stop()
- }
-
- override fun onError(utteranceId: String?, errorCode: Int) {
- stop()
- }
- })
- if (shouldSpeakWhenReady) {
- shouldSpeakWhenReady = false
- speakCurrent()
- }
- } else {
- stop()
+ fun refreshSettings(context: Context) {
+ val appContext = context.applicationContext
+ mutableSettingsState.value = mutableSettingsState.value.copy(loading = true, message = null)
+ engineProbe?.shutdown()
+ var probe: TextToSpeech? = null
+ probe = TextToSpeech(appContext) { status ->
+ if (status != TextToSpeech.SUCCESS) {
+ mutableSettingsState.value = mutableSettingsState.value.copy(
+ loading = false,
+ message = "Could not load Android TTS engines.",
+ )
+ probe?.shutdown()
+ if (engineProbe === probe) engineProbe = null
+ return@TextToSpeech
}
+
+ val engines = probe?.engines.orEmpty()
+ .map { ReadAloudEngineOption(id = it.name, label = it.label ?: it.name) }
+ .sortedBy { it.label.lowercase() }
+ var selectedEngine = selectedEngineId(appContext).takeIf { saved ->
+ saved != null && engines.any { it.id == saved }
+ }
+ if (selectedEngine == null && !appContext.readAloudPrefs().getBoolean(EngineChoiceMadeKey, false)) {
+ selectedEngine = engines.firstOrNull { it.isRhVoice() }?.id
+ if (selectedEngine != null) {
+ appContext.readAloudPrefs().edit().putString(SelectedEngineKey, selectedEngine).apply()
+ }
+ }
+ mutableSettingsState.value = mutableSettingsState.value.copy(
+ engines = engines,
+ selectedEngineId = selectedEngine,
+ )
+ probe?.shutdown()
+ if (engineProbe === probe) engineProbe = null
+ refreshVoices(appContext, selectedEngine)
+ }
+ engineProbe = probe
+ }
+
+ fun selectEngine(context: Context, engineId: String?) {
+ val appContext = context.applicationContext
+ appContext.readAloudPrefs()
+ .edit()
+ .putBoolean(EngineChoiceMadeKey, true)
+ .putStringOrRemove(SelectedEngineKey, engineId)
+ .remove(SelectedVoiceKey)
+ .apply()
+ resetTts()
+ mutableSettingsState.value = mutableSettingsState.value.copy(
+ selectedEngineId = engineId,
+ selectedVoiceId = null,
+ voices = emptyList(),
+ loading = true,
+ message = null,
+ )
+ refreshVoices(appContext, engineId)
+ }
+
+ fun selectVoice(context: Context, voiceId: String?) {
+ val appContext = context.applicationContext
+ appContext.readAloudPrefs()
+ .edit()
+ .putStringOrRemove(SelectedVoiceKey, voiceId)
+ .apply()
+ mutableSettingsState.value = mutableSettingsState.value.copy(selectedVoiceId = voiceId)
+ applyTtsConfiguration(appContext)
+ if (mutableState.value.playing && ttsReady) {
+ speakCurrent()
+ }
+ }
+
+ private fun ensureTts(context: Context) {
+ val appContext = context.applicationContext
+ val selectedEngine = selectedEngineId(appContext)
+ if (tts != null && ttsEngineId == selectedEngine) return
+ resetTts()
+ ttsEngineId = selectedEngine
+ tts = if (selectedEngine == null) {
+ TextToSpeech(appContext) { status ->
+ handleTtsInit(appContext, status)
+ }
+ } else {
+ TextToSpeech(
+ appContext,
+ { status -> handleTtsInit(appContext, status) },
+ selectedEngine,
+ )
+ }
+ }
+
+ private fun handleTtsInit(context: Context, status: Int) {
+ ttsReady = status == TextToSpeech.SUCCESS
+ if (ttsReady) {
+ applyTtsConfiguration(context)
+ tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
+ override fun onStart(utteranceId: String?) = Unit
+
+ override fun onDone(utteranceId: String?) {
+ handleUtteranceDone(utteranceId)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun onError(utteranceId: String?) {
+ stop()
+ }
+
+ override fun onError(utteranceId: String?, errorCode: Int) {
+ stop()
+ }
+ })
+ if (shouldSpeakWhenReady) {
+ shouldSpeakWhenReady = false
+ speakCurrent()
+ }
+ } else {
+ stop()
}
}
@@ -156,7 +271,7 @@ private object AndroidReadAloudEngine {
} else {
TextToSpeech.QUEUE_FLUSH
}
- tts?.speak(sentence.text, queueMode, params, "$SpeakPrefix$currentIndex")
+ tts?.speak(sentence.spokenText, queueMode, params, "$SpeakPrefix$currentIndex")
if (sentence.pauseAfterMillis > 0) {
tts?.playSilentUtterance(sentence.pauseAfterMillis, TextToSpeech.QUEUE_ADD, "$AfterPausePrefix$currentIndex")
}
@@ -182,8 +297,107 @@ private object AndroidReadAloudEngine {
stop()
}
}
+
+ private fun refreshVoices(context: Context, engineId: String?) {
+ voiceProbe?.shutdown()
+ var probe: TextToSpeech? = null
+ probe = if (engineId == null) {
+ TextToSpeech(context) { status ->
+ handleVoiceProbeInit(context, probe, status)
+ }
+ } else {
+ TextToSpeech(
+ context,
+ { status -> handleVoiceProbeInit(context, probe, status) },
+ engineId,
+ )
+ }
+ voiceProbe = probe
+ }
+
+ private fun handleVoiceProbeInit(context: Context, probe: TextToSpeech?, status: Int) {
+ if (status != TextToSpeech.SUCCESS) {
+ mutableSettingsState.value = mutableSettingsState.value.copy(
+ loading = false,
+ message = "Could not load voices for the selected TTS engine.",
+ )
+ probe?.shutdown()
+ if (voiceProbe === probe) voiceProbe = null
+ return
+ }
+
+ val voices = probe?.voices.orEmpty()
+ .mapNotNull { voice ->
+ val locale = voice.locale ?: return@mapNotNull null
+ ReadAloudVoiceOption(
+ id = voice.name,
+ label = voice.name,
+ localeTag = locale.toLanguageTag(),
+ networkRequired = voice.isNetworkConnectionRequired,
+ )
+ }
+ .filter { !it.networkRequired }
+ .sortedWith(
+ compareByDescending { it.localeTag.startsWith("ru", ignoreCase = true) }
+ .thenBy { it.localeTag }
+ .thenBy { it.label.lowercase() },
+ )
+ val savedVoice = selectedVoiceId(context).takeIf { saved ->
+ saved != null && voices.any { it.id == saved }
+ }
+ mutableSettingsState.value = mutableSettingsState.value.copy(
+ voices = voices,
+ selectedVoiceId = savedVoice,
+ loading = false,
+ message = if (voices.isEmpty()) "No offline voices reported by the selected TTS engine." else null,
+ )
+ if (savedVoice == null && selectedVoiceId(context) != null) {
+ context.readAloudPrefs().edit().remove(SelectedVoiceKey).apply()
+ }
+ probe?.shutdown()
+ if (voiceProbe === probe) voiceProbe = null
+ }
+
+ private fun applyTtsConfiguration(context: Context) {
+ val selectedVoice = selectedVoiceId(context)
+ val voice = selectedVoice?.let { voiceName ->
+ tts?.voices?.firstOrNull { it.name == voiceName && !it.isNetworkConnectionRequired }
+ }
+ if (voice != null) {
+ tts?.voice = voice
+ } else {
+ tts?.setLanguage(RussianLocale)
+ }
+ }
+
+ private fun resetTts() {
+ shouldSpeakWhenReady = false
+ ttsReady = false
+ ttsEngineId = null
+ tts?.stop()
+ tts?.shutdown()
+ tts = null
+ }
+
+ private fun selectedEngineId(context: Context): String? =
+ context.readAloudPrefs().getString(SelectedEngineKey, null)
+
+ private fun selectedVoiceId(context: Context): String? =
+ context.readAloudPrefs().getString(SelectedVoiceKey, null)
+
+ private fun Context.readAloudPrefs() =
+ getSharedPreferences(SettingsPrefs, Context.MODE_PRIVATE)
+
+ private fun ReadAloudEngineOption.isRhVoice(): Boolean =
+ id.contains("rhvoice", ignoreCase = true) || label.contains("RHVoice", ignoreCase = true)
}
+private fun android.content.SharedPreferences.Editor.putStringOrRemove(
+ key: String,
+ value: String?,
+): android.content.SharedPreferences.Editor =
+ if (value == null) remove(key) else putString(key, value)
+
class ReadAloudService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt
index 7d8e02b..e666fd8 100644
--- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt
+++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt
@@ -8,6 +8,7 @@ data class ReadAloudSentence(
val start: Int,
val endExclusive: Int,
val text: String,
+ val spokenText: String = text,
val pauseBeforeMillis: Long = 0,
val pauseAfterMillis: Long = 0,
)
@@ -18,12 +19,37 @@ data class ReadAloudState(
val sentenceIndex: Int? = null,
)
+data class ReadAloudEngineOption(
+ val id: String,
+ val label: String,
+)
+
+data class ReadAloudVoiceOption(
+ val id: String,
+ val label: String,
+ val localeTag: String,
+ val networkRequired: Boolean,
+)
+
+data class ReadAloudSettingsState(
+ val engines: List = emptyList(),
+ val selectedEngineId: String? = null,
+ val voices: List = emptyList(),
+ val selectedVoiceId: String? = null,
+ val loading: Boolean = false,
+ val message: String? = null,
+)
+
expect object ReadAloudPlatform {
val isSupported: Boolean
val state: StateFlow
+ val settingsState: StateFlow
fun prepare(bookTitle: String, sentences: List, startIndex: Int)
fun play()
fun stop()
fun skip(delta: Int)
+ fun refreshSettings()
+ fun selectEngine(engineId: String?)
+ fun selectVoice(voiceId: String?)
}
diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt
index 5d884ea..bad8a44 100644
--- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt
+++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt
@@ -93,11 +93,7 @@ internal fun ContinuousBookReader(
val hyphenation = remember { HyphenationRegistry() }
val scope = rememberCoroutineScope()
val textLineMetricsByItem = remember(contentPlan) { mutableStateMapOf() }
- val contentPadding = if (isAndroidPlatform()) {
- PaddingValues(start = 0.dp, top = 6.dp, end = 0.dp, bottom = 6.dp)
- } else {
- PaddingValues(horizontal = 4.dp, vertical = 6.dp)
- }
+ val contentPadding = PaddingValues(6.dp)
LazyColumn(
state = listState,
@@ -174,10 +170,8 @@ internal fun ContinuousBookReader(
hyphenation = hyphenation,
style = readerParagraphTextStyle(book.language),
highlightedRange = highlightedRange,
- /* Justify adds extra padding to the end, which hardly can be removed */
textAlign = TextAlign.Justify,
- // so we add 6.dp to make it look symmetric
- modifier = Modifier.padding(start = (element.depth * 8).dp + 6.dp, end = 0.dp),
+ modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp),
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() },
)
is ReaderElement.Subtitle -> ReaderText(
@@ -444,7 +438,7 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
language = book.language,
hyphenation = hyphenation,
style = readerParagraphTextStyle(book.language),
- textAlign = TextAlign.Justify,
+ textAlign = TextAlign.Unspecified,
)
is Fb2Block.Subtitle -> ReaderText(
text = block.content,
@@ -473,7 +467,7 @@ private fun ReaderText(
) {
val highlightColor = MaterialTheme.colorScheme.secondaryContainer
val annotatedText = text.toAnnotatedString(language, hyphenation, highlightedRange, highlightColor)
- val needsSoftHyphenPaintWorkaround = isDesktopPlatform()
+ val needsSoftHyphenPaintWorkaround = isDesktopPlatform
var textLayout by remember(annotatedText) { mutableStateOf(null) }
val desktopHyphenColor = MaterialTheme.colorScheme.onSurface
val desktopHyphenGutter = 8.dp
@@ -525,19 +519,22 @@ private fun ReaderText(
@Composable
private fun readerParagraphTextStyle(language: String?): TextStyle =
MaterialTheme.typography.bodyLarge.copy(
- fontWeight = if( isAndroidPlatform()) FontWeight(350) else FontWeight.Normal,
- fontSize = if( isAndroidPlatform()) 21.sp else 18.sp,
- lineHeight = 28.sp,
- hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified,
- lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified,
+ fontWeight = if( isAndroidPlatform) FontWeight(350) else FontWeight.Normal,
+ fontSize = if( isAndroidPlatform) 19.sp else 18.sp,
+ lineHeight = 26.sp,
+ letterSpacing = if (isAndroidPlatform) 0.sp else MaterialTheme.typography.bodyLarge.letterSpacing,
+ hyphens = if (isAndroidPlatform) Hyphens.Auto else Hyphens.Unspecified,
+ lineBreak = if (isAndroidPlatform) LineBreak.Paragraph else LineBreak.Unspecified,
localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) },
)
-private fun isAndroidPlatform(): Boolean =
+private val isAndroidPlatform: Boolean by lazy {
getPlatform().name.startsWith("Android")
+}
-private fun isDesktopPlatform(): Boolean =
+private val isDesktopPlatform: Boolean by lazy {
getPlatform().name.startsWith("Java")
+}
private fun TextLayoutResult.endsAtSoftHyphen(text: String, line: Int): Boolean {
val end = getLineEnd(line, visibleEnd = false)
@@ -663,6 +660,7 @@ private fun AnnotatedString.Builder.appendWithHighlight(
internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
val elements = mutableListOf()
val sentences = mutableListOf()
+ var pendingPauseBeforeMillis = 0L
fun addTextSentences(
itemIndex: Int,
@@ -670,17 +668,27 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
pauseBeforeMillis: Long = 0,
pauseAfterMillis: Long = 0,
) {
- text.plainText().sentenceRanges().forEach { range ->
- val sentenceText = text.plainText().substring(range.start, range.endExclusive).trim()
- if (sentenceText.isNotEmpty()) {
+ val plainText = text.plainText()
+ if (plainText.isReadAloudPauseBreak()) {
+ pendingPauseBeforeMillis = max(pendingPauseBeforeMillis, StarBreakPauseMillis)
+ return
+ }
+
+ plainText.sentenceRanges().forEach { range ->
+ val sentenceText = plainText.substring(range.start, range.endExclusive).trim()
+ val spokenText = sentenceText.toReadAloudSpokenText()
+ if (spokenText.isNotEmpty()) {
+ val effectivePauseBefore = pauseBeforeMillis + pendingPauseBeforeMillis
+ pendingPauseBeforeMillis = 0L
sentences += ReadAloudSentence(
index = sentences.size,
itemIndex = itemIndex,
start = range.start,
endExclusive = range.endExclusive,
text = sentenceText,
- pauseBeforeMillis = pauseBeforeMillis,
- pauseAfterMillis = pauseAfterMillis,
+ spokenText = spokenText,
+ pauseBeforeMillis = effectivePauseBefore,
+ pauseAfterMillis = pauseAfterMillis + range.pauseAfterMillis,
)
}
}
@@ -698,9 +706,10 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
start = 0,
endExclusive = section.title!!.length,
text = section.title!!,
- pauseBeforeMillis = HeadingPauseBeforeMillis,
+ pauseBeforeMillis = HeadingPauseBeforeMillis + pendingPauseBeforeMillis,
pauseAfterMillis = HeadingPauseAfterMillis,
)
+ pendingPauseBeforeMillis = 0L
}
section.readableBlocks().forEach { block ->
val itemIndex = elements.size
@@ -756,11 +765,22 @@ internal sealed interface ReaderElement {
private data class ReaderSentenceRange(
val start: Int,
val endExclusive: Int,
+ val pauseAfterMillis: Long = 0L,
)
private fun Fb2Text.plainText(): String =
spans.joinToString(separator = "") { it.text }
+private fun String.isReadAloudPauseBreak(): Boolean {
+ val compact = trim()
+ return compact.length >= 3 && compact.all { it == '*' || it.isWhitespace() }
+}
+
+private fun String.toReadAloudSpokenText(): String =
+ replace(Regex("\\.{2,}"), ".")
+ .replace('…', '.')
+ .trim()
+
private fun String.sentenceRanges(): List {
val ranges = mutableListOf()
var start = 0
@@ -773,9 +793,15 @@ private fun String.sentenceRanges(): List {
var index = start
while (index < length) {
if (this[index].isSentenceTerminator()) {
- var end = index + 1
+ val terminatorEnd = sentenceTerminatorEnd(index)
+ val pauseAfterMillis = if (hasReadAloudEllipsisAt(index, terminatorEnd)) {
+ EllipsisPauseAfterMillis
+ } else {
+ 0L
+ }
+ var end = terminatorEnd
while (end < length && this[end] in "\"'»”’)]}") end += 1
- ranges += ReaderSentenceRange(start, end)
+ ranges += ReaderSentenceRange(start, end, pauseAfterMillis)
start = end
skipLeadingWhitespace()
index = start
@@ -789,11 +815,23 @@ private fun String.sentenceRanges(): List {
return ranges
}
+private fun String.sentenceTerminatorEnd(index: Int): Int {
+ if (this[index] != '.') return index + 1
+ var end = index + 1
+ while (end < length && this[end] == '.') end += 1
+ return end
+}
+
+private fun String.hasReadAloudEllipsisAt(index: Int, terminatorEnd: Int): Boolean =
+ this[index] == '…' || (this[index] == '.' && terminatorEnd - index >= 2)
+
private fun Char.isSentenceTerminator(): Boolean =
this == '.' || this == '!' || this == '?' || this == '…'
private const val HeadingPauseBeforeMillis = 1_000L
private const val HeadingPauseAfterMillis = 600L
+private const val StarBreakPauseMillis = 1_200L
+private const val EllipsisPauseAfterMillis = 350L
private fun List.flattenSections(depth: Int = 0): List =
flatMapIndexed { index, section ->
diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt
index e4ca890..7fc3e62 100644
--- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt
+++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt
@@ -21,6 +21,8 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Stop
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -34,6 +36,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -74,7 +77,9 @@ internal fun BookView(
var restored by remember(fileId) { mutableStateOf(false) }
var markedRead by remember(fileId) { mutableStateOf(false) }
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
+ var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
val readAloudState by ReadAloudPlatform.state.collectAsState()
+ val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState()
val platformName = getPlatform().name
val showShareAction = platformName.startsWith("Android")
val showViewFileAction = platformName.startsWith("Java")
@@ -249,11 +254,24 @@ internal fun BookView(
},
onBack = { ReadAloudPlatform.skip(-1) },
onForward = { ReadAloudPlatform.skip(1) },
+ onSettings = {
+ ReadAloudPlatform.refreshSettings()
+ readAloudSettingsVisible = true
+ },
)
}
}
}
}
+
+ if (readAloudSettingsVisible) {
+ ReadAloudSettingsDialog(
+ state = readAloudSettings,
+ onDismiss = { readAloudSettingsVisible = false },
+ onEngineSelected = { ReadAloudPlatform.selectEngine(it) },
+ onVoiceSelected = { ReadAloudPlatform.selectVoice(it) },
+ )
+ }
}
@Composable
@@ -372,6 +390,7 @@ private fun ReadAloudPanel(
onPlayStop: () -> Unit,
onBack: () -> Unit,
onForward: () -> Unit,
+ onSettings: () -> Unit,
) {
Surface(
tonalElevation = 3.dp,
@@ -396,9 +415,95 @@ private fun ReadAloudPanel(
IconButton(onClick = onForward) {
Icon(Icons.Filled.FastForward, contentDescription = "Next sentence")
}
- IconButton(onClick = {}, enabled = false) {
+ IconButton(onClick = onSettings) {
Icon(Icons.Filled.Settings, contentDescription = "Read aloud settings")
}
}
}
}
+
+@Composable
+private fun ReadAloudSettingsDialog(
+ state: ReadAloudSettingsState,
+ onDismiss: () -> Unit,
+ onEngineSelected: (String?) -> Unit,
+ onVoiceSelected: (String?) -> Unit,
+) {
+ var engineMenuOpen by remember { mutableStateOf(false) }
+ var voiceMenuOpen by remember { mutableStateOf(false) }
+ val selectedEngineLabel = state.engines.firstOrNull { it.id == state.selectedEngineId }?.label ?: "System default"
+ val selectedVoiceLabel = state.voices.firstOrNull { it.id == state.selectedVoiceId }
+ ?.let { "${it.label} (${it.localeTag})" }
+ ?: "Auto Russian"
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Read aloud") },
+ text = {
+ Column {
+ Text("TTS engine", style = MaterialTheme.typography.labelMedium)
+ Box {
+ TextButton(onClick = { engineMenuOpen = true }) {
+ Text(selectedEngineLabel)
+ }
+ DropdownMenu(expanded = engineMenuOpen, onDismissRequest = { engineMenuOpen = false }) {
+ DropdownMenuItem(
+ text = { Text("System default") },
+ onClick = {
+ engineMenuOpen = false
+ onEngineSelected(null)
+ },
+ )
+ state.engines.forEach { engine ->
+ DropdownMenuItem(
+ text = { Text(engine.label) },
+ onClick = {
+ engineMenuOpen = false
+ onEngineSelected(engine.id)
+ },
+ )
+ }
+ }
+ }
+
+ Text("Offline voice", style = MaterialTheme.typography.labelMedium)
+ Box {
+ TextButton(onClick = { voiceMenuOpen = true }, enabled = !state.loading && state.voices.isNotEmpty()) {
+ Text(selectedVoiceLabel)
+ }
+ DropdownMenu(expanded = voiceMenuOpen, onDismissRequest = { voiceMenuOpen = false }) {
+ DropdownMenuItem(
+ text = { Text("Auto Russian") },
+ onClick = {
+ voiceMenuOpen = false
+ onVoiceSelected(null)
+ },
+ )
+ state.voices.forEach { voice ->
+ DropdownMenuItem(
+ text = { Text("${voice.label} (${voice.localeTag})") },
+ onClick = {
+ voiceMenuOpen = false
+ onVoiceSelected(voice.id)
+ },
+ )
+ }
+ }
+ }
+
+ if (state.loading) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ CircularProgressIndicator()
+ Text("Loading voices...", modifier = Modifier.padding(start = 12.dp))
+ }
+ }
+ state.message?.let { Text(it, color = MaterialTheme.colorScheme.error) }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Done")
+ }
+ },
+ )
+}
diff --git a/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt b/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt
new file mode 100644
index 0000000..a5b63bd
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt
@@ -0,0 +1,58 @@
+package net.sergeych.toread
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import net.sergeych.toread.fb2.Fb2Block
+import net.sergeych.toread.fb2.Fb2Book
+import net.sergeych.toread.fb2.Fb2Section
+import net.sergeych.toread.fb2.Fb2Text
+import net.sergeych.toread.fb2.Fb2TextSpan
+
+class ReadAloudContentPlanTest {
+ @Test
+ fun starBreakAddsPauseBeforeNextSpokenSentence() {
+ val plan = buildReaderContentPlan(
+ Fb2Book(
+ title = "Book",
+ sections = listOf(
+ Fb2Section(
+ blocks = listOf(
+ paragraph("Before."),
+ paragraph("* * *"),
+ paragraph("After."),
+ ),
+ ),
+ ),
+ ),
+ )
+
+ assertEquals(listOf("Before.", "After."), plan.sentences.map { it.text })
+ assertEquals(0L, plan.sentences[0].pauseBeforeMillis)
+ assertEquals(1_200L, plan.sentences[1].pauseBeforeMillis)
+ }
+
+ @Test
+ fun ellipsisIsCollapsedForSpeechAndAddsShortPause() {
+ val plan = buildReaderContentPlan(
+ Fb2Book(
+ title = "Book",
+ sections = listOf(
+ Fb2Section(blocks = listOf(paragraph("Wait... Go.. Stop… Done."))),
+ ),
+ ),
+ )
+
+ assertEquals(
+ listOf("Wait...", "Go..", "Stop…", "Done."),
+ plan.sentences.map { it.text },
+ )
+ assertEquals(
+ listOf("Wait.", "Go.", "Stop.", "Done."),
+ plan.sentences.map { it.spokenText },
+ )
+ assertEquals(listOf(350L, 350L, 350L, 0L), plan.sentences.map { it.pauseAfterMillis })
+ }
+
+ private fun paragraph(text: String): Fb2Block.Paragraph =
+ Fb2Block.Paragraph(Fb2Text(listOf(Fb2TextSpan(text))))
+}
diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/ReadAloudPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/ReadAloudPlatform.jvm.kt
index 31efd51..d5ca2ea 100644
--- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/ReadAloudPlatform.jvm.kt
+++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/ReadAloudPlatform.jvm.kt
@@ -7,9 +7,14 @@ actual object ReadAloudPlatform {
actual val isSupported: Boolean = false
private val mutableState = MutableStateFlow(ReadAloudState())
actual val state: StateFlow = mutableState
+ private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState())
+ actual val settingsState: StateFlow = mutableSettingsState
actual fun prepare(bookTitle: String, sentences: List, startIndex: Int) = Unit
actual fun play() = Unit
actual fun stop() = Unit
actual fun skip(delta: Int) = Unit
+ actual fun refreshSettings() = Unit
+ actual fun selectEngine(engineId: String?) = Unit
+ actual fun selectVoice(voiceId: String?) = Unit
}
diff --git a/composeApp/src/webMain/kotlin/net/sergeych/toread/ReadAloudPlatform.web.kt b/composeApp/src/webMain/kotlin/net/sergeych/toread/ReadAloudPlatform.web.kt
index 31efd51..d5ca2ea 100644
--- a/composeApp/src/webMain/kotlin/net/sergeych/toread/ReadAloudPlatform.web.kt
+++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/ReadAloudPlatform.web.kt
@@ -7,9 +7,14 @@ actual object ReadAloudPlatform {
actual val isSupported: Boolean = false
private val mutableState = MutableStateFlow(ReadAloudState())
actual val state: StateFlow = mutableState
+ private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState())
+ actual val settingsState: StateFlow = mutableSettingsState
actual fun prepare(bookTitle: String, sentences: List, startIndex: Int) = Unit
actual fun play() = Unit
actual fun stop() = Unit
actual fun skip(delta: Int) = Unit
+ actual fun refreshSettings() = Unit
+ actual fun selectEngine(engineId: String?) = Unit
+ actual fun selectVoice(voiceId: String?) = Unit
}
diff --git a/docs/russian-hyphenation-rules.md b/docs/russian-hyphenation-rules.md
new file mode 100644
index 0000000..34afb9b
--- /dev/null
+++ b/docs/russian-hyphenation-rules.md
@@ -0,0 +1,181 @@
+# Russian Hyphenation Rules
+
+This document defines the rule set for the Russian hyphenation plugin. The
+implementation should return the original word with soft hyphens inserted at
+preferred transfer points. Tests may render soft hyphens as `-` for readability.
+
+The goal is not perfect dictionary hyphenation. The goal is a deterministic,
+readable rule set that avoids invalid transfers and produces better break points
+than the current vowel/consonant heuristic.
+
+## Character Classes
+
+Use lowercase character checks for classification.
+
+- Vowels: `а е ё и о у ы э ю я`
+- Consonants: Russian letters except vowels, `ь`, `ъ`, and `й`
+- Non-syllabic letters: `ь ъ`
+- Semivowel: `й`
+
+Only hyphenate words made of Russian letters. Leave mixed words, words with
+digits, existing soft hyphens, and abbreviations unchanged. Short words are
+hyphenated only when they pass the same legal side-length and vowel filters as
+longer words.
+
+## Hard Legal Filters
+
+A candidate break is illegal if any of these is true:
+
+- Either side would have fewer than 2 letters.
+- Either side would contain no vowel.
+- The right side starts with `ь`, `ъ`, `й`, or `ы`.
+- The left side ends before `ь` or `ъ`; that is, do not split `под-ъезд` or
+ `бол-ьшой`. Prefer `подъ-езд`, `боль-шой`.
+- The left side ends before `й`; that is, do not split `ма-йор` or `во-йна`.
+ Prefer `май-ор`, `вой-на`.
+- The break separates a consonant from a following vowel: reject `люб-овь`,
+ `паст-ух`, `реб-ята`. Prefer `лю-бовь`, `па-стух` or `пас-тух`, `ре-бята`
+ or `ребя-та`.
+
+## Candidate Generation
+
+Work between adjacent vowel nuclei. For each span from one vowel to the next,
+choose preferred break candidates from the consonant cluster between them.
+
+### Adjacent Vowels
+
+If two vowels are adjacent, allow a break between them when both resulting parts
+pass the legal filters.
+
+Examples:
+
+- `поэт` -> `по-эт`
+- `академия` -> `ака-де-мия` and not `а-кадемия` or `академи-я`
+
+### One Consonant Between Vowels
+
+For `V C V`, break before the consonant.
+
+Examples:
+
+- `молоко` -> `мо-ло-ко`
+- `корова` -> `ко-ро-ва`
+- `переход` -> `пе-ре-ход`
+
+### Two Consonants Between Vowels
+
+For `V C C V`, prefer a break between the consonants.
+
+Examples:
+
+- `лампа` -> `лам-па`
+- `гордый` -> `гор-дый`
+- `письмо` -> `пись-мо`
+
+If the cluster contains `й`, `ь`, or `ъ`, keep that letter on the left and break
+after it when legal.
+
+Examples:
+
+- `майор` -> `май-ор`
+- `подъезд` -> `подъ-езд`
+- `большой` -> `боль-шой`
+
+### Three Or More Consonants Between Vowels
+
+For longer clusters, prefer the latest break that still leaves a pronounceable
+left part, but keep common inseparable starts on the right:
+
+- Keep `ст`, `ск`, `сп`, `сн`, `сл`, `см`, `св` together on the right when
+ possible.
+- Keep stop/liquid pairs together on the right when possible: `бр`, `бл`,
+ `вр`, `вл`, `гр`, `гл`, `др`, `тр`, `кр`, `кл`, `пр`, `пл`, `фр`, `фл`.
+- Otherwise prefer splitting before the last consonant in the cluster.
+
+Examples:
+
+- `сестра` -> `се-стра`
+- `острый` should not use `о-стрый`, because the left side is too short
+- `родство` -> `род-ство`
+- `чувство` -> `чув-ство`
+- `предложение` -> `пред-ло-же-ние`
+
+## Double Consonants
+
+When two identical consonants stand between vowels, prefer splitting between
+them.
+
+Examples:
+
+- `масса` -> `мас-са`
+- `длинный` -> `длин-ный`
+- `касса` -> `кас-са`
+
+Do not force this rule when the double consonant starts a root after a prefix.
+Without a dictionary, this exception is hard to detect, so implementation may
+leave such words to the general cluster logic.
+
+## Prefix-Like Boundaries
+
+Without a morphology dictionary, treat these as preferred heuristics only.
+
+If a word starts with a common prefix and the following part is legal, prefer a
+break after the prefix:
+
+- `без`, `бес`, `воз`, `вос`, `вз`, `вс`, `из`, `ис`, `низ`, `нис`, `раз`,
+ `рас`, `роз`, `рос`, `от`, `об`, `объ`, `под`, `подъ`, `пред`, `пере`,
+ `при`, `про`, `над`, `сверх`, `меж`
+
+Examples:
+
+- `подбить` -> `под-бить`
+- `размах` -> `раз-мах`
+- `предложение` -> `пред-ло-же-ние`
+- `подъезд` -> `подъ-езд`
+
+Do not create a right side starting with `ы`; prefer later legal breaks.
+
+Examples:
+
+- `разыскать` -> `ра-зыскать` or `разыс-кать`, not `раз-ыскать`
+- `розыгрыш` -> `ро-зыгрыш` or `розыг-рыш`, not `роз-ыгрыш`
+
+## Ranking
+
+A word may have several legal break points. The plugin should insert all good
+breaks, but it should avoid noisy low-quality breaks. Use this ranking:
+
+1. Prefix boundary, if legal.
+2. Double consonant split between vowels.
+3. Syllable breaks from adjacent vowel and consonant-cluster rules.
+4. Longer-cluster fallback break before the last consonant.
+
+Reject candidates that are legal but awkward when a better candidate is within
+one character and both candidates divide the same vowel-to-vowel span.
+
+## Example Expectations
+
+These strings use `-` where the implementation will insert `SoftHyphen`.
+
+```text
+молоко -> мо-ло-ко
+корова -> ко-ро-ва
+яблоко -> яб-ло-ко
+повествование -> по-вест-во-ва-ние
+предложение -> пред-ло-же-ние
+компьютер -> компью-тер
+подъезд -> подъ-езд
+большой -> боль-шой
+майор -> май-ор
+масса -> мас-са
+длинный -> длин-ный
+разыскать -> ра-зыс-кать
+розыгрыш -> ро-зыг-рыш
+```
+
+## Non-Goals
+
+- Full dictionary-level hyphenation.
+- Stress-aware syllabification.
+- Exact morpheme detection for every prefix/root boundary.
+- Hyphenating proper abbreviations and mixed-script technical identifiers.
diff --git a/shared/src/commonMain/kotlin/net/sergeych/toread/text/Hyphenation.kt b/shared/src/commonMain/kotlin/net/sergeych/toread/text/Hyphenation.kt
index bc16e18..e88a293 100644
--- a/shared/src/commonMain/kotlin/net/sergeych/toread/text/Hyphenation.kt
+++ b/shared/src/commonMain/kotlin/net/sergeych/toread/text/Hyphenation.kt
@@ -29,22 +29,23 @@ class HyphenationRegistry(
private fun hyphenate(text: String, plugin: HyphenationPlugin): String = buildString(text.length + text.length / 12) {
var wordStart = -1
- fun flushWord(end: Int) {
+ fun flushToken(end: Int) {
if (wordStart >= 0) {
- append(plugin.hyphenateWord(text.substring(wordStart, end)))
+ val token = text.substring(wordStart, end)
+ append(if (SoftHyphen !in token && token.all(Char::isLetter)) plugin.hyphenateWord(token) else token)
wordStart = -1
}
}
text.forEachIndexed { index, char ->
- if (char.isLetter()) {
+ if (char.isLetterOrDigit() || char == SoftHyphen) {
if (wordStart < 0) wordStart = index
} else {
- flushWord(index)
+ flushToken(index)
append(char)
}
}
- flushWord(text.length)
+ flushToken(text.length)
}
}
@@ -71,19 +72,124 @@ object RussianHyphenationPlugin : HyphenationPlugin {
override val languageTags: Set = setOf("ru", "rus")
override fun hyphenateWord(word: String): String {
- if (word.length < 6 || SoftHyphen in word) return word
- val breaks = mutableListOf()
- for (index in 2 until word.lastIndex) {
- val prev = word[index - 1]
- val current = word[index]
- val next = word[index + 1]
- if (current.isRussianVowel() && next.isRussianConsonant()) breaks += index + 1
- if (prev.isRussianVowel() && current.isRussianConsonant() && next.isRussianVowel()) breaks += index
+ if (SoftHyphen in word || !word.isRussianWord() || word.isLikelyAbbreviation()) return word
+
+ val lower = word.lowercase()
+ val candidates = mutableSetOf()
+ addRussianPrefixBreak(lower, candidates)
+ addRussianSyllableBreaks(lower, candidates)
+
+ return insertBreaks(word, candidates.filter { lower.isLegalRussianBreak(it) }, minPrefix = 2, minSuffix = 2)
+ }
+
+ private fun addRussianPrefixBreak(word: String, candidates: MutableSet) {
+ RussianPrefixes.firstOrNull { prefix -> word.startsWith(prefix) }
+ ?.length
+ ?.let(candidates::add)
+ }
+
+ private fun addRussianSyllableBreaks(word: String, candidates: MutableSet) {
+ val vowelIndexes = word.indices.filter { word[it].isRussianVowel() }
+ vowelIndexes.zipWithNext().forEach { (leftVowel, rightVowel) ->
+ val clusterStart = leftVowel + 1
+ val clusterEnd = rightVowel
+ val clusterLength = clusterEnd - clusterStart
+ when {
+ clusterLength == 0 -> candidates += rightVowel
+ clusterLength == 1 -> {
+ val consonant = word[clusterStart]
+ candidates += if (consonant.isRussianJoiner()) clusterStart + 1 else clusterStart
+ }
+ clusterLength == 2 -> {
+ val joiner = (clusterStart until clusterEnd).firstOrNull { word[it].isRussianJoiner() }
+ candidates += if (joiner != null) joiner + 1 else clusterStart + 1
+ }
+ else -> addRussianClusterBreak(word, clusterStart, clusterEnd, candidates)
+ }
}
- return insertBreaks(word, breaks, minPrefix = 2, minSuffix = 2)
+ }
+
+ private fun addRussianClusterBreak(
+ word: String,
+ clusterStart: Int,
+ clusterEnd: Int,
+ candidates: MutableSet,
+ ) {
+ val cluster = word.substring(clusterStart, clusterEnd)
+ val joiner = (clusterStart until clusterEnd).firstOrNull { word[it].isRussianJoiner() }
+ if (joiner != null) {
+ if (joiner < clusterEnd - 1) candidates += joiner + 1
+ return
+ }
+
+ if (cluster == "ств") {
+ candidates += clusterStart + 2
+ return
+ }
+
+ if (cluster.endsWith("ств")) {
+ candidates += clusterEnd - 3
+ return
+ }
+
+ val onsetBreak = (clusterStart until clusterEnd)
+ .firstOrNull { index -> RussianRightOnsets.any { onset -> word.startsWith(onset, index) } }
+ candidates += onsetBreak ?: (clusterEnd - 1)
}
}
+private val RussianPrefixes = listOf(
+ "сверх",
+ "подъ",
+ "пере",
+ "пред",
+ "без",
+ "бес",
+ "воз",
+ "вос",
+ "низ",
+ "нис",
+ "раз",
+ "рас",
+ "роз",
+ "рос",
+ "под",
+ "при",
+ "про",
+ "над",
+ "меж",
+ "вз",
+ "вс",
+ "из",
+ "ис",
+ "от",
+ "об",
+)
+
+private val RussianRightOnsets = listOf(
+ "ст",
+ "ск",
+ "сп",
+ "сн",
+ "сл",
+ "см",
+ "св",
+ "бр",
+ "бл",
+ "вр",
+ "вл",
+ "гр",
+ "гл",
+ "др",
+ "тр",
+ "кр",
+ "кл",
+ "пр",
+ "пл",
+ "фр",
+ "фл",
+)
+
const val SoftHyphen: Char = '\u00AD'
private fun insertBreaks(word: String, breaks: List, minPrefix: Int, minSuffix: Int): String {
@@ -107,4 +213,24 @@ private fun Char.isConsonant(): Boolean = isLetter() && !isVowel()
private fun Char.isRussianVowel(): Boolean = lowercaseChar() in "аеёиоуыэюя"
private fun Char.isRussianConsonant(): Boolean =
- lowercaseChar() in "бвгджзйклмнпрстфхцчшщ"
+ lowercaseChar() in "бвгджзклмнпрстфхцчшщ"
+
+private fun Char.isRussianJoiner(): Boolean = lowercaseChar() in "йьъ"
+
+private fun Char.isRussianLetter(): Boolean = lowercaseChar() in "абвгдеёжзийклмнопрстуфхцчшщъыьэюя"
+
+private fun String.isRussianWord(): Boolean = all(Char::isRussianLetter)
+
+private fun String.isLikelyAbbreviation(): Boolean = length > 1 && all { it.isUpperCase() }
+
+private fun String.isLegalRussianBreak(index: Int): Boolean {
+ if (index < 2 || length - index < 2) return false
+ if (take(index).none(Char::isRussianVowel) || drop(index).none(Char::isRussianVowel)) return false
+
+ val left = this[index - 1]
+ val right = this[index]
+ if (right.lowercaseChar() in "ьъйы") return false
+ if (right.isRussianJoiner()) return false
+ if (left.isRussianConsonant() && right.isRussianVowel()) return false
+ return true
+}
diff --git a/shared/src/commonTest/kotlin/net/sergeych/toread/text/HyphenationTest.kt b/shared/src/commonTest/kotlin/net/sergeych/toread/text/HyphenationTest.kt
index 950fd1b..f3d4673 100644
--- a/shared/src/commonTest/kotlin/net/sergeych/toread/text/HyphenationTest.kt
+++ b/shared/src/commonTest/kotlin/net/sergeych/toread/text/HyphenationTest.kt
@@ -1,13 +1,16 @@
package net.sergeych.toread.text
import kotlin.test.Test
+import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
class HyphenationTest {
+ private val hyphenation = HyphenationRegistry()
+
@Test
fun selectsEnglishPluginByLanguage() {
- val hyphenated = HyphenationRegistry().hyphenate("composition", "en")
+ val hyphenated = hyphenation.hyphenate("composition", "en")
assertNotEquals("composition", hyphenated)
assertTrue(SoftHyphen in hyphenated)
@@ -15,9 +18,41 @@ class HyphenationTest {
@Test
fun selectsRussianPluginByLanguage() {
- val hyphenated = HyphenationRegistry().hyphenate("повествование", "ru")
+ val hyphenated = hyphenation.hyphenate("повествование", "ru")
assertNotEquals("повествование", hyphenated)
assertTrue(SoftHyphen in hyphenated)
}
+
+ @Test
+ fun suggestsRussianHyphensAtReadableBreaks() {
+ mapOf(
+ "молоко" to "мо-ло-ко",
+ "корова" to "ко-ро-ва",
+ "яблоко" to "яб-ло-ко",
+ "повествование" to "по-вест-во-ва-ние",
+ "предложение" to "пред-ло-же-ние",
+ "компьютер" to "компью-тер",
+ "подъезд" to "подъ-езд",
+ "большой" to "боль-шой",
+ "майор" to "май-ор",
+ "масса" to "мас-са",
+ "длинный" to "длин-ный",
+ "разыскать" to "ра-зыс-кать",
+ "розыгрыш" to "ро-зыг-рыш",
+ ).forEach { (word, expected) ->
+ assertEquals(expected, word.hyphenatedRu())
+ }
+ }
+
+ @Test
+ fun keepsRussianWordsWithoutLegalReadableBreaksUnchanged() {
+ listOf("дом", "мир", "стол", "семья", "СССР", "testовый", "книга123").forEach { word ->
+ assertEquals(word, word.hyphenatedRu())
+ }
+ assertEquals("ко-рова", "ко${SoftHyphen}рова".hyphenatedRu())
+ }
+
+ private fun String.hyphenatedRu(): String =
+ hyphenation.hyphenate(this, "ru").replace(SoftHyphen, '-')
}