Skip to main content

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.typed and passes mypy --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 (&amp;, &#x27;) 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 tag and rest_tag elements 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> &amp; <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 &amp; <b>Jer</b>ry &lt;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:

  1. Words made only of digits are skipped unless emphasize_numbers=True.
  2. Words shorter than min_word_length are skipped.
  3. A single-character word is emphasised only when s >= 3.
  4. Otherwise, calculate prefixLen using 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't is one word; well-known splits 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.

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)

Source distribution for smooth-reading 0.3.0
File Size Uploaded
smooth_reading-0.3.0.tar.gz 27.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for smooth-reading 0.3.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page