Guided fixation reading for Python: 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 package is an independent, clean-room, zero-dependency Apache-2.0 implementation. The algorithm is formally defined in docs/SPEC.md, guaranteeing deterministic, byte-identical output with the TypeScript, Swift, and Kotlin ports across all shared fixtures.
- Python 3.10+ supported.
- Zero runtime dependencies: Pure Python standard library (
unicodedata,html,argparse). - Fully type-annotated: Ships
py.typedand passesmypy --strict. - Unicode-aware: Accurately handles diacritics, combining marks, contractions, and multilingual scripts.
- Built-in CLI: Pipe files or stdin directly to HTML or Markdown.
pip install smooth-readingfrom smooth_reading import to_html
to_html("Smooth reading works.")
# '<b>Smo</b>oth <b>read</b>ing <b>wor</b>ks.'from smooth_reading import to_html
# Emphasise every 2nd word at strength 5:
to_html("Smooth reading works.", fixation=5, saccade=2)
# '<b>Smoot</b>h reading <b>work</b>s.'
# Style with semantic spans and classes:
to_html(
"Smooth reading",
tag="span",
class_name="sr-fixation",
rest_tag="span",
rest_class_name="sr-rest",
)
# '<span class="sr-fixation">Smo</span><span class="sr-rest">oth</span> <span class="sr-fixation">read</span><span class="sr-rest">ing</span>'Pair with the core package stylesheet:
.sr-fixation { font-weight: 700; }
.sr-rest { opacity: var(--sr-rest-opacity, 1); }from smooth_reading import tokenize
for token in tokenize("Smooth reading", fixation=4):
if token.type == "word":
print(token.fixation_text, "|", token.rest_text)
# Smoo | th
# readi | ngtokenize returns frozen Token dataclasses (WordToken and SeparatorToken), allowing custom renderers (Rich terminal UI, ReportLab PDF, Jinja templates, or AST builders) to render fixation text without parsing HTML.
from smooth_reading import to_markdown
to_markdown("Smooth reading works.")
# '**Smo**oth **read**ing **wor**ks.'By default (ignore_html_tags=True), to_html treats the input as HTML:
- Existing tags, comments, and character references (
&,') pass through untouched and act as word boundaries. - Text within tags listed in
skip_tags(code,pre,script,style,kbd,samp,textarea) is never altered. - Fixation
tagandrest_tagelements are automatically protected against duplicate nesting. - Emitted text characters (
&,<,>,") are safely escaped.
from smooth_reading import to_html
to_html("<p>Smooth reading</p> <code>x = 1</code> <b>done</b> & gone")
# '<p><b>Smo</b>oth <b>read</b>ing</p> <code>x = 1</code> <b>done</b> & <b>go</b>ne'Pass ignore_html_tags=False to treat the input as plain text and escape all HTML characters:
to_html("Tom & Jerry <3", ignore_html_tags=False)
# '<b>To</b>m & <b>Jer</b>ry <3'The package includes a standalone CLI tool, smooth-reading:
# Convert a file to HTML
smooth-reading article.txt > article.html
# Read from standard input with custom strength and saccade
cat article.txt | smooth-reading --fixation 4 --saccade 2
# Generate Markdown output
smooth-reading --markdown notes.txt
# Use custom tags and classes
smooth-reading --tag span --class sr-fixation article.txt| Flag | Default | Description |
|---|---|---|
file |
stdin |
Input file path; omit or specify - to read standard input. |
--fixation {1..5} |
3 |
Fixation strength (ratio of word emphasised). |
--saccade N |
1 |
Saccade frequency: emphasise every N-th word. |
--min-word-length N |
1 |
Skip words with fewer than N grapheme clusters. |
--numbers |
off | Also emphasise words composed entirely of decimal digits. |
--tag NAME |
b |
HTML tag wrapping each fixation prefix. |
--class NAME |
none | Optional class attribute on the fixation element. |
--no-ignore-html-tags |
off | Treat input as plain text and escape all markup. |
--markdown |
off | Emit Markdown emphasis (**prefix**rest) instead of HTML. |
tokenize, to_html, and to_markdown accept algorithm options as keyword arguments, as an Options instance (to_html(text, Options(fixation=4))), or both (keywords override instance fields).
Out-of-range fixation (outside 1–5), saccade (< 1) and min_word_length (< 0) values are automatically clamped into valid ranges. A numeric value that is not an integer is truncated toward zero first and then clamped — fixation=4.9 is a strength of 4, exactly as Math.trunc does in the TypeScript core — and a non-finite value (nan, inf) falls back to the default. Values of the wrong type (a str, a bool) still raise ValueError.
| Option | Type | Default | Description |
|---|---|---|---|
fixation |
1..5 |
3 |
Ratio of word emphasised: 0.20, 0.35, 0.50, 0.65, 0.80. Non-integers truncated, out-of-range values clamped. |
saccade |
int >= 1 |
1 |
Saccade interval: emphasise every N-th word. Non-integers truncated, values < 1 clamped to 1. |
min_word_length |
int >= 0 |
1 |
Words shorter than this get no fixation. Non-integers truncated, negative values behave like 0. |
emphasize_numbers |
bool |
False |
Whether to emphasise words consisting solely of digits. |
locale |
str | None |
None |
BCP-47 tag, accepted for parity across ports (unused by regex scanner). |
fixation_length |
Callable |
None |
Custom callback (word, graphemes, options) -> int replacing the fixation calculation. |
to_html additionally accepts: tag, class_name, rest_tag, rest_class_name, ignore_html_tags, and skip_tags.
to_markdown additionally accepts: marker (default "**").
For a word of n grapheme clusters and strength s:
ratio = {1: 0.20, 2: 0.35, 3: 0.50, 4: 0.65, 5: 0.80}[s]
prefixLen = clamp(floor(n * ratio + 0.5), 1, n)
Evaluation rules are applied in the following order:
- Words made only of digits are skipped unless
emphasize_numbers=True. - Words shorter than
min_word_lengthare skipped. - A single-character word is emphasised only when
s >= 3. - Otherwise, calculate
prefixLenusing integer-based half-up rounding (floor((n * percent + 50) / 100)).
Python's built-in re module does not support Unicode \p{...} properties and \w erroneously includes underscores. The tokenizer is implemented as a fast scanner over unicodedata.category.
- Word boundaries: Words are maximal runs of letters, numbers, and marks, optionally connected by internal apostrophes (
don'tis one word;well-knownsplits at hyphens into two words). - Combining marks: A word never begins with a combining mark; marks following separators (e.g. variation selectors in emoji sequences like
❤️) remain attached to the separator. - Grapheme clusters: Evaluated using a lightweight UAX #29 approximation without external dependencies:
- Base characters plus following combining marks (
Mn,Mc,Me) form a single cluster. - Zero-width joiners (ZWJ) connect to following characters.
- Decomposed Hangul jamo sequences compose into complete syllables.
- Indic conjuncts for Unicode 15.1 linker scripts (Devanagari, Bengali, Gujarati, Oriya, Telugu, Malayalam — such as
क्ष) remain unified as single clusters. - CR+LF pairs are treated as single clusters.
- Base characters plus following combining marks (
All space-separated scripts (Latin, Greek, Cyrillic, Korean, Vietnamese, Indic scripts, Arabic, Hebrew, Persian, Urdu) render byte-identically to ICU-backed ports (fixtures/common/scripts.json).
For scripts without spaces (Chinese, Japanese, Korean without spaces, Thai, Lao, Burmese, Khmer), the package applies a predictable continuous run rule: each unbroken run of such characters is one word (我喜欢阅读 → <b>我喜欢</b>阅读). A run is split from the adjacent letters and digits of every other script, and combining marks following a run character stay attached to the run:
to_html("iPhone手机") # '<b>iPh</b>one<b>手</b>机' -- two words
to_html("日本語OKです") # '<b>日本</b>語<b>O</b>K<b>で</b>す' -- three wordsThe exact code-point table (and the requirement that a character's general category be L, N or M) is in SPEC §3; smooth_reading.tokenizer mirrors it in _CJK_RANGES. This avoids third-party C/ICU dependencies while ensuring deterministic execution.
Because there is no dictionary, the run rule is a documented degradation, not a match for ICU: 手机很好用 stays one word where ICU would find three, and 3.14, 1,000, snake_case, ½ and X'0 also segment differently. SPEC §3 lists each accepted difference; fixtures/common/ contains only inputs on which every port agrees.
to_html uses the same markup grammar as the TypeScript core (SPEC §4.6): a tag name begins at the first ASCII letter (after an optional /, which may be followed by whitespace) and runs until whitespace, / or >. So <code.x> is named code.x and is not the code skip tag, </ code> closes a code element, and <code / > is self-closing and never opens one.
to_html("<code.x>hello</code.x>") # '<code.x><b>hel</b>lo</code.x>'
to_html("<code>a</ code>b") # '<code>a</ code><b>b</b>'
to_html("<code / >hello</code>") # '<code / ><b>hel</b>lo</code>'For detailed cross-platform comparisons, see docs/LANGUAGES.md.
uv venv && uv pip install -e ".[dev]"
.venv/bin/python -m pytest
.venv/bin/python -m mypy --strict smooth_reading
.venv/bin/python -m ruff check && .venv/bin/python -m ruff format --checkApache-2.0. See LICENSE.