Guided fixation reading for Android and Kotlin/JVM: the leading letters of every word are emphasised so the eye lands on an artificial fixation point and the brain completes the rest of the word.
Similar to commercial fixation-reading products, this library is an independent, clean-room, Apache-2.0 implementation built strictly to the monorepo specification, producing identical results to the TypeScript, Swift, and Python ports.
Two distinct artifacts are published:
| Artifact | Target | Description |
|---|---|---|
io.github.pythonshe:smooth-reading |
Android (minSdk 24+) | Full Android library: spanned() and setSmoothText() for TextView, annotatedString() for Jetpack Compose, and IcuWordSegmenter for dictionary word breaking. |
io.github.pythonshe:smooth-reading-core |
Any JVM target | Pure Kotlin/JVM, zero dependencies: tokenize(), toHtml(), fixationLength(), and SpecWordSegmenter. Ideal for server-side rendering, desktop, and backend pipelines. |
Every code snippet below is verified by unit tests in ReadmeSnippetsTest.
// build.gradle.kts — Android app or library
dependencies {
implementation("io.github.pythonshe:smooth-reading:0.3.0")
}
// build.gradle.kts — Plain Kotlin/JVM project (SSR, desktop, CLI)
dependencies {
implementation("io.github.pythonshe:smooth-reading-core:0.3.0")
}The Android artifact includes io.github.pythonshe:smooth-reading-core transitively. The Maven group is io.github.pythonshe; the Kotlin package stays io.smoothreading. Its only other dependency, androidx.compose.ui:ui-text (for AnnotatedString and SpanStyle), is declared compileOnly:
- View-only applications incur no Compose runtime overhead in their APK. The Compose adapter class (
SmoothReadingCompose) is only loaded whenannotatedString()is invoked, and consumer ProGuard rules include-dontwarn androidx.compose.ui.**. - Jetpack Compose applications already have
ui-textprovided transitively viaandroidx.compose.ui:ui. The library requires no Compose compiler plugin and adds minimal method count.
import io.smoothreading.SmoothOptions
import io.smoothreading.SmoothReading
import io.smoothreading.android.setSmoothText
import io.smoothreading.android.spanned
textView.setSmoothText("Smooth reading works.")
// Options, plus a dimmed colour on the rest of each word:
textView.setSmoothText(
"Smooth reading works.",
SmoothOptions(fixation = 4, saccade = 1),
restSpan = { ForegroundColorSpan(0x99000000.toInt()) },
)
// Or build the Spannable yourself:
textView.text = SmoothReading.spanned("Smooth reading works.")spanned()returns aSpannableStringover the original input text.- Fixations apply
StyleSpan(Typeface.BOLD)by default (override viafixationSpan = { … }). - Spans are provided via factory lambdas because each Android span instance covers exactly one range. Words without a fixation receive no span, compositing naturally with the view's existing typography.
import androidx.compose.material3.Text
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import io.smoothreading.SmoothOptions
import io.smoothreading.SmoothReading
import io.smoothreading.android.annotatedString
@Composable
fun Article(body: String) {
Text(
SmoothReading.annotatedString(
body,
options = SmoothOptions(fixation = 3, saccade = 1),
fixationStyle = SpanStyle(fontWeight = FontWeight.Bold),
restStyle = SpanStyle(color = LocalContentColor.current.copy(alpha = 0.75f)),
)
)
}annotatedString() is a standard Kotlin function (not @Composable), making it safe to compute in ViewModel classes, background coroutines, or remember(body) blocks.
import io.smoothreading.HtmlOptions
import io.smoothreading.SmoothOptions
import io.smoothreading.SmoothReading
SmoothReading.toHtml("Smooth reading works.")
// <b>Smo</b>oth <b>read</b>ing <b>wor</b>ks.
SmoothReading.toHtml(
"Smooth reading works.",
HtmlOptions(SmoothOptions(fixation = 4), tag = "span", className = "sr-fixation"),
)
// <span class="sr-fixation">Smoo</span>th …By default (ignoreHtmlTags = true), existing HTML tags, comments, and character entities (&) pass through unchanged and act as word boundaries. Set ignoreHtmlTags = false to treat input as plain text and escape all HTML characters.
Access structured tokens directly:
SmoothReading.tokenize("well-known").forEach { token ->
when (token) {
is SmoothToken.Word -> println("${token.fixationText}|${token.restText}")
is SmoothToken.Separator -> println("sep ${token.text}")
}
}
// we|ll
// sep -
// kno|wnTo replace the fixation algorithm entirely:
val firstHalf = SmoothOptions(fixationLength = { _, graphemes, _ -> graphemes / 2 })
SmoothReading.toHtml("reading", firstHalf) // <b>rea</b>dingOut-of-range values are clamped at construction, never rejected (SPEC §4, guarantee 2):
fixation into 1..5, saccade to >= 1 and minWordLength to >= 0.
| Option | Type | Default | Description |
|---|---|---|---|
fixation |
Int (1..5) |
3 |
Fixation strength: ratios 0.20 / 0.35 / 0.50 / 0.65 / 0.80, rounded half up and clamped to 1..n. |
saccade |
Int (≥ 1) |
1 |
Emphasises every n-th word (1 = every word, 2 = every second word). Word tokens consume an index, including numbers and short words. |
minWordLength |
Int (≥ 0) |
1 |
Words with fewer grapheme clusters receive no fixation. |
emphasizeNumbers |
Boolean |
false |
Whether words composed entirely of decimal digits are emphasised. |
locale |
java.util.Locale? |
null |
Locale passed to break iterators (falls back to runtime default if null). |
fixationLength |
((String, Int, SmoothOptions) -> Int)? |
null |
Custom lambda overriding the fixation algorithm. Output is clamped to 0..graphemes. |
HtmlOptions wraps SmoothOptions in its smooth property:
| Option | Type | Default | Description |
|---|---|---|---|
smooth |
SmoothOptions |
SmoothOptions() |
The base algorithm configuration. |
tag |
String |
"b" |
Tag name wrapping the fixation prefix. |
className |
String? |
null |
Optional class attribute for the fixation element. |
restTag |
String? |
null |
Optional tag name wrapping the remainder of the word. Words without fixation are never wrapped. |
restClassName |
String? |
null |
Optional class attribute for the rest element. |
ignoreHtmlTags |
Boolean |
true |
Preserves existing tags and character references (&, '). When false, treats input as plain text. |
skipTags |
List<String> |
code, pre, script, style, kbd, samp, textarea |
Case-insensitive list of element tag names whose inner text is never altered. |
| Segmenter | Environment | Characteristics |
|---|---|---|
SpecWordSegmenter |
All JVM runtimes (default for tokenize() / toHtml()) |
Spec-compliant Unicode scanner ([\p{L}\p{N}\p{M}]+(?:['’][\p{L}\p{N}\p{M}]+)*), grouping each continuous run of a no-space script (Han, Kana, Hangul, Thai, Lao, Myanmar, Khmer) into one word, split from the letters and digits around it (iPhone手机 is two words). Guarantees byte-identical output with fixtures/common/. |
IcuWordSegmenter |
Android (default for spanned() / annotatedString()) |
Built on android.icu.text.BreakIterator. Provides full ICU dictionary-based segmentation for Chinese, Japanese, and Thai. Passes fixtures/segmenter/. |
The JVM default deliberately avoids java.text.BreakIterator for word breaking, as legacy JDK break iterators misclassify hyphens and contractions.
Grapheme cluster counting leverages BreakIterator.getCharacterInstance(), supplemented with a post-pass for Hangul jamo composition and Indic conjunct linking (Unicode 15.1 rule GB9c). This ensures conjuncts like क्ष remain unified as single clusters across all supported JDK versions.
You can specify a segmenter explicitly when needed:
SmoothReading.toHtml("我喜欢阅读", segmenter = IcuWordSegmenter(Locale.CHINESE))
// <b>我</b><b>喜</b>欢<b>阅</b>读 (dictionary word breaks)
SmoothReading.toHtml("我喜欢阅读", segmenter = SpecWordSegmenter)
// <b>我喜欢</b>阅读 (one word per CJK run)
SmoothReading.spanned("我喜欢阅读", SmoothOptions(), segmenter = SpecWordSegmenter)- Android SDK:
minSdk24 (theandroid.icu.text.BreakIteratorfloor),compileSdk37 - Bytecode target: Java 11 bytecode compiled with a JDK 21 toolchain
- Build toolchain: Gradle 9.7.1, Android Gradle Plugin 9.4.0, Kotlin 2.4.20
cd android
./gradlew :smooth-reading-core:test :smooth-reading:test :smooth-reading:assembleRelease
./gradlew publishToMavenLocalApache-2.0. See LICENSE.