Improve Android read aloud TTS handling
This commit is contained in:
1 parent
df90b4c36f
commit
8bb920caf8
11 files changed
+870
-71
No files matched your search
@@ -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<String> = setOf("ru", "rus")
|
||||
|
||||
override fun hyphenateWord(word: String): String {
|
||||
if (word.length < 6 || SoftHyphen in word) return word
|
||||
val breaks = mutableListOf<Int>()
|
||||
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<Int>()
|
||||
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<Int>) {
|
||||
RussianPrefixes.firstOrNull { prefix -> word.startsWith(prefix) }
|
||||
?.length
|
||||
?.let(candidates::add)
|
||||
}
|
||||
|
||||
private fun addRussianSyllableBreaks(word: String, candidates: MutableSet<Int>) {
|
||||
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<Int>,
|
||||
) {
|
||||
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<Int>, 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
|
||||
}
|
||||
@@ -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, '-')
|
||||
}
|
||||
Reference in new issue
Block a user