Skip to main content

tervatrix-rtl

Correct right-to-left text for Hebrew and Arabic, in one dependency-free module. Pure standard library, Python 3.9+, MIT.

It fixes the four bidi bugs that ship in almost every RTL application and are invisible in review, because they only appear once real data lands in the layout:

what the user sees why the fix
+972-4-000-0000 renders as 0000-000-4-972+ the dashes are neutral characters and inherit the paragraph's RTL direction prepare_outbound() wraps it in a left-to-right isolate
opening hours 09:00-18:00 render as 18:00-09:00, closing time first two number runs with a neutral between them get reordered as a unit the pattern treats a time range as one run
a whole Hebrew line flips to left-aligned because it starts with 2026 or WhatsApp the first-strong-character rule guesses the paragraph direction an invisible right-to-left mark is prefixed to the line
a prepared string no longer equals the string it came from the marks are invisible but real characters strip_bidi(), the exact inverse

Extracted from a WhatsApp agent answering Hebrew, Arabic and English customers in production.


Install

pip install tervatrix-rtl

No dependencies. Nothing to configure.


The 30-second version

from tervatrix_rtl import prepare_outbound

message = "שעות פתיחה: 09:00-18:00\nלשאלות: +972-4-000-0000"

send(prepare_outbound(message, "he"))

That one call wraps the time range and the phone number in left-to-right isolates and marks the line that starts with a digit. An English string is returned byte for byte untouched, so English never carries invisible control characters into your logs, your tests or your database.


API

prepare_outbound(text, language=None)

The single call to make on any string before you render or send it. Runs isolate_ltr_runs then force_rtl_paragraphs. Returns LTR languages unchanged.

Omit language and it is detected from the script the text is written in.

isolate_ltr_runs(text)

Wraps URLs, www. hosts, e-mail addresses, phone numbers, prices and time ranges in U+2066 … U+2069.

Idempotent, and composable: it processes only what lies outside any isolate that is already there. So you can wrap one value by hand with isolate(), drop it into a sentence, and still pass the whole sentence through prepare_outbound() — the hand-wrapped value survives untouched and everything around it is still protected.

force_rtl_paragraphs(text)

Prefixes U+200F to every line that does not already begin with a strong RTL character. Applied line by line, which is why an isolate is never allowed to cross a newline — a pair split over two lines would never balance.

isolate(value)

Wraps one value unconditionally. Use it when you already know what the value is and the pattern is deliberately too strict to catch it — a bare order id like A7, a two-digit table number.

f"הזמנה {isolate(order.id)} מוכנה"

format_time_range(start, end, separator="-")

format_time_range("09:00", "18:00") gives an isolated 09:00-18:00 that cannot be reordered.

detect_language(text) → 'he' | 'ar' | 'en'

Counts characters per script rather than stopping at the first hit, so one Latin brand name inside a Hebrew sentence does not switch the whole string to English. URLs, e-mail addresses and phone numbers are removed before counting - they are not evidence of a language, and a single link otherwise carries enough Latin letters to flip a short Hebrew message to English. Falls back to 'en' for digits-only, emoji-only and empty input.

is_rtl(language) and dir_for(language)

is_rtl accepts a bare subtag or a full tag: he, he-IL, ar_EG, fa-IR, ur. dir_for returns 'rtl' or 'ltr', ready to drop into an HTML dir attribute.

strip_bidi(text)

Removes every bidi control character, including the legacy embedding marks (U+202A–U+202E) found in old translation files.

Call this before comparing strings. The marks are invisible, so an unstripped string fails an equality check for no visible reason — in a test assertion, a database WHERE, a de-duplication key. strip_bidi(prepare_outbound(x)) is exactly x.

LRI, PDI, RLM, RTL_LANGUAGES

The control characters (U+2066, U+2069, U+200F) and the tuple of RTL primary subtags, exported so you can assert on them in your own tests.


Where it belongs in a web app

Do the isolating once, on the way out, not scattered through templates:

# FastAPI / Flask / Django - one place, at the edge
from tervatrix_rtl import dir_for, prepare_outbound

@app.get("/order/{order_id}")
def order_page(order_id: str, lang: str = "he"):
    order = repo.get(order_id)
    return render(
        "order.html",
        dir=dir_for(lang),
        lang=lang,
        summary=prepare_outbound(order.summary, lang),
    )

And strip on the way in, before anything is compared or stored:

from tervatrix_rtl import strip_bidi

clean = strip_bidi(request.form["name"]).strip()

That second line matters more than it looks. Text pasted from a chat app, a PDF or a spreadsheet routinely carries U+200F and U+202A–U+202E. Stored unstripped, the row never matches on lookup and the bug is invisible in every log and every database client.


Tests

No framework to install; unittest ships with Python.

python -m unittest discover -s tests -t .

They cover language detection, isolate placement, idempotency, isolate/newline balance, and the round trip through strip_bidi.

Parity with the JavaScript package is enforced rather than described. tests/parity_fixture.json is generated by running both implementations over the same corpus; the generator refuses to write it while they disagree on any case, so the fixture can only ever record agreement. Both repositories commit that file and assert against it, which means a drift in either language fails a test instead of being absorbed the next time it is regenerated.

python tools/build_parity_fixture.py --rtl-ui ../rtl-ui   # regenerate, deliberately

Licence

MIT — see LICENSE. Use it commercially, fork it, ship it. No warranty.


Where this came from

This package is the RTL layer of a WhatsApp agent that answers Hebrew, Arabic and English customers on the official Meta Cloud API. Two more pieces of it are public and free:

  • tervatrix-rtl-ui — the identical logic in JavaScript, plus logical-property CSS utilities and an optional React wrapper. Same function names, same behaviour, so a string prepared on either side of a project is byte-identical.
  • wa-agent-starter — a complete, working WhatsApp agent in FastAPI: Meta webhook, HMAC signature verification, language detection, keyword FAQ, this RTL handling, and 50 passing tests. MIT, self-hosted, no account with us.

If you need more than a keyword bot, wa-agent-kit is the paid version. It adds a Qdrant knowledge base so the agent answers questions nobody wrote a keyword for, multi-tenant support for many business numbers on one deployment, human handover, an admin panel, an AI-provider abstraction, and 12 months of updates.

→ Get wa-agent-kit

Metadata

Release files for tervatrix-rtl 1.0.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tervatrix-rtl 1.0.1
File Size Uploaded
tervatrix_rtl-1.0.1.tar.gz 19.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tervatrix-rtl 1.0.1
File Interpreter ABI Platform
tervatrix_rtl-1.0.1-py3-none-any.whl Python 3 none any Details

Total release size: 30.1 kB

Release files / tervatrix_rtl-1.0.1.tar.gz

Download URL tervatrix_rtl-1.0.1.tar.gz
Size 19.2 kB
Tags Source
SHA-256 checksum
How to use checksums
ea96facf9b1b85fcbc0e411dd240eec17990b59252d2609fae3d169e290854b7
BLAKE2b-256 checksum
How to use checksums
f09a4b093b54dca52598b62c85a8271151b419e6fb2d1232ae504ff9a7284303
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.9

Release files / tervatrix_rtl-1.0.1-py3-none-any.whl

Download URL tervatrix_rtl-1.0.1-py3-none-any.whl
Size 11.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
cf724d840bdadba4e9ce547be780448f5946a8de26c73bc804c282531474dd0a
BLAKE2b-256 checksum
How to use checksums
12074ab3d1f711c323dc47590cfcf540545b5b554b7d0e9620ee7cc13542bc1d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.9

Release history Release notifications | RSS feed

This release

1.0.1 This release

2 release files

1.0.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