smooth-reading
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.
Installation
pip install smooth-reading
Examples
1. Render HTML
from smooth_reading import to_html
to_html("Smooth reading works.")
# '<b>Smo</b>oth <b>read</b>ing <b>wor</b>ks.'
2. Configure strength, saccade intervals, and custom CSS classes
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); }
3. Work directly with tokens
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 | ng
tokenize 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.
4. Render Markdown
from smooth_reading import to_markdown
to_markdown("Smooth reading works.")
# '**Smo**oth **read**ing **wor**ks.'
HTML parsing and markup handling
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'
Command-Line Interface (CLI)
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
CLI flags
| 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. |
Configuration options
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 "**").
Algorithm
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)).
Tokenizer & Unicode handling
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 (
Script behavior
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 words
The 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.
HTML lexing
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.
Development
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 --check
License
Apache-2.0. See LICENSE.
Metadata
Release files for smooth-reading 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| smooth_reading-0.3.0.tar.gz | 27.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| smooth_reading-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 50.8 kB
Release files / smooth_reading-0.3.0.tar.gz
| Download URL | smooth_reading-0.3.0.tar.gz |
|---|---|
| Size | 27.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e061a6a1390ae81d7360e000d6022dfa3d8548b575fb78cf7658eea316cf4415
|
|
BLAKE2b-256 checksum How to use checksums |
8ead3ac9dfab818660d09df604b84b944e48ac87a60aa019ab9c3062c10df599
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 11, 2026.
Transparency logRelease files / smooth_reading-0.3.0-py3-none-any.whl
| Download URL | smooth_reading-0.3.0-py3-none-any.whl |
|---|---|
| Size | 22.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e474d85d496216781f60b85e95a11cd699ce7e92e99a368ef62076eaef96eeb6
|
|
BLAKE2b-256 checksum How to use checksums |
e715cb6039710096ee8504ba234f8a53532365ae1a13c861cd7aa8c5d2d9f5d7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 11, 2026.
Transparency log