Changelog: 0.6.x

Archived verbatim from CHANGELOG.md. Newer releases are in the changelog; the archive index lists every older series.

[0.6.3] — 2026-06-08

A correctness, maintenance, and architecture-foundation release. No output-affecting changes — every fix is behaviour-preserving and the one new public behaviour (slugify(default=...)) is opt-in. Headline: a pure-Rust error model is now in place, laying the foundation for the multi-language bindings on the roadmap.

Upgrade notes

  • No output-affecting changes. Existing output and every exception type/message are unchanged.
  • New opt-in: slugify(text, default="…") returns the fallback when the input has no sluggable characters (emoji / punctuation / zero-width) instead of "". default=None (the default) preserves the prior empty-string behaviour.

Added

  • slugify(default=...) — opt-in fallback for inputs that would otherwise slug to the empty string, closing an empty-slug routing hazard (#97).

Fixed

  • PRESETS["strip_obfuscation"] metadata now reflects the real pipeline order (confusables runs after demojize), matching src/presets.rs (#141).
  • Lock-poison recovery now emits a Python UserWarning naming the recovered table, instead of a silent stderr line (#117).
  • docs/api/exceptions.md corrected — TranslitError inherits from ValueError (not Exception), and every example message string now matches the real output (#182).

Changed (internal — behaviour-preserving)

  • Error model (#181, part of #180): a pure-Rust Error enum (thiserror) with a stable code() per variant and a single From<Error> for PyErr boundary; ~35 error sites migrated off in-core PyErr construction. Removes the core↔PyO3 coupling and lays the foundation for non-Python bindings. Python exception types and messages are unchanged.
  • Dependencies: phf / phf_codegen 0.11 → 0.13, criterion 0.5 → 0.8, chardetng 0.1 → 1.0 — each migrated and verified behaviour-preserving (#146, #153, #164).
  • build.rs now auto-discovers language override tables — adding a language is just dropping in a translit_lang_*.tsv (#74).
  • Generated .pyi stubs are now guarded by a stub/binary signature drift-check, which caught and fixed 18 stale stub signatures (#76).

Maintenance

  • Split python/translit/__init__.py (2,683 lines) into _api.py + _presets.py (#73).
  • Split tests/integration_transliterate.rs by script family (#75).
  • Process: a required "Conversations resolved" merge gate (#55); a documented dependency-upgrade methodology with Dependabot cooldown + auto-merge (DEPENDENCY_UPGRADES.md, RELEASING.md).

[0.6.2] — 2026-06-07

A correctness, security, performance and maintenance release triaged from a post-0.6.1 issue sweep (#101–#132). No public API removed; one small new public behaviour (slugify(save_order=True) now functions). Two output-affecting fixes — see Upgrade notes.

Upgrade notes (output-affecting)

  • slugify(save_order=True) was an accepted no-op; it now strips only leading/trailing stopwords (preserving interior word order), matching python-slugify (#118). If you passed save_order=True, slug output changes.
  • decode_to_utf8 default min_confidence 0.50.95 (#103). The old default was inert (the detector only reports 0.50/0.95, and 0.50 < 0.50 is false), so it never rejected. It now requires high confidence by default; pass min_confidence=0.0 to accept any guess. (No practical change today — the detector currently always reports 0.95.)

Fixed

  • #102UniqueSlugify no longer panics across the FFI boundary on a multibyte separator + small max_length (byte slice landed mid-codepoint; now uses floor_char_boundary).
  • #101 — context bigram disambiguation tier was unreachable (it reset on every inter-word space); it now resets only on hard boundaries, so the tier fires in normal prose.
  • #104set_emoji_provider now obeys seal_registrations() (the provider swap previously defeated the seal).
  • #103decode_to_utf8 default confidence now actually gates (see notes).
  • #107 — a corrupt context dictionary now reports a distinct "corrupt" error instead of the misleading "not found" remedy (DictState enum).
  • #121PRESETS["sanitize_user_input"] now reflects the real pipeline order (strip invisibles before zalgo); Python registry and Rust doc aligned.
  • #129Text.transliterate() stub now declares the tones/context parameters the implementation accepts.
  • #131Slugify(uids=...) emits a correct wrong-class warning rather than a spurious deprecation warning.
  • #122 — disambiguated the _compat should_warn nested ternary.

Security

  • #105 — added a cargo audit (RustSec advisory) CI job and a cargo Dependabot ecosystem.
  • #132 — added a Trivy CVE scan of the published image to the release workflow (SARIF → Security tab, fails on fixable HIGH/CRITICAL) + .trivyignore.
  • #106 — Rust diagnostics now route through Python warnings instead of bare eprintln!, so applications can capture/suppress them.

Performance (output-preserving)

  • #108 codepoint-range diacritic checks in tokenize(); #109 mem::take per token boundary; #110 single ch.nfkc() pass on the NFKC fallback; #111 lowered MAX_CAPACITY_HINT 256 MiB → 8 MiB; #112/#113 emoji matching uses stack buffers + a fixed sliding window (no per-char Vec/String); #114 slugify uses Cow (no eager to_owned); #115 context tokenize() returns borrowed (Cow) slices of the input — zero per-token allocation (Rust API: the crate-internal context::Token.text changed from String to Cow<'_, str>; no effect on the Python API); #116 clamped the ContextDict capacity hint.

Maintenance

  • #118 implemented slugify(save_order=True); #119 SlugConfig::from_pyargs dedupes the four slugify PyO3 entrypoints; #120 _build_slug_kwargs helper; #123 seal-enforcement docs on each tables:: mutator; #124 infallibility comments; #125 typed _CallableModule.__call__ kwargs; #126 corrected recover_lock doc; #127 documented the lazy-import workaround; #128 renamed _mutation_generation_registration_generation; #130 annotated the defence-in-depth conflict check.

[0.6.1] — 2026-06-07

A bug-fix and test-hardening release. No public API was removed and no new public names were added. One fix changes key output for inputs containing invisible characters — see Upgrade notes.

Upgrade notes (output-affecting fix)

  • search_key / catalog_key / sort_key now strip bidi overrides and soft-hyphen / format characters (#93). Previously a value stored with an invisible character (e.g. "pass\u00adword", "user\u202etxt") produced a different key from its clean equivalent, so dedup and lookup silently missed. The new key is the correct one; if you persist these keys, regenerate any that were computed over text that could contain invisible characters.

Fixed

  • #93 — key functions (search_key/catalog_key/sort_key) leaked bidi and soft-hyphen characters, so visually-identical inputs produced non-colliding keys. They now strip_bidi after NFKC, matching the other canonicalization presets.
  • #82 — Greek reverse transliteration (transliterate(text, target="el")) left literal Latin letters in the output ("psychi""ψyχη"). The forward direction romanizes Υ/υ as Y/y (including the ου/αυ/ευ diphthongs), so the el reverse table now maps Y/y back to Greek; round-trips no longer leak Latin letters.
  • #69transliterate() resolved conflicting kwargs differently for str vs list input (one path silently dropped target, the other context). Conflicts are now checked once, before the dispatch, so both raise identically: context+target and context+tones raise ValueError.
  • #72translit.unidecode() now mirrors the Unidecode 1.3 signature unidecode(string, errors="ignore", replace_str="?"), mapping Unidecode's errors modes (ignore/replace/preserve/strict) onto the native error handling, instead of raising TypeError on those kwargs.
  • #95 — Greek Extended polytonic capitals for omicron/upsilon/omega/rho were corrupted, emitting unrelated Latin letters (ὍμηροςXmiros, ὙγίειαPgieia). Corrected all 50 affected entries to the proper base romanization, consistent with the monotonic forms (ὍμηροςOmiros).
  • #99.3 — a typo'd form=/errors= value now raises even for pure-ASCII input. Previously the ASCII fast-path returned before reaching Rust, so the bad enum silently no-opped on ASCII and only raised on the first non-ASCII string. Validation now runs before the fast-path in normalize() and transliterate().

Performance

  • #70 — the batch entry points (transliterate, slugify, normalize, strip_accents on list[str]) now release the GIL around their pure-Rust compute loop via py.allow_threads. Multi-threaded callers processing large batches now get real parallelism (~1.8× wall-clock with two threads) instead of serialising on the interpreter lock. Output is unchanged. Documented in the new "Concurrency (GIL)" section of docs/performance.md.

Documentation

  • #94strict_iso9 is no longer described as "ISO 9:1995". It emits ASCII digraphs (ж→zh, ч→ch, ш→sh), not the standard's diacritics (ž/č/š) — translit tables are ASCII-only by design. Docstrings, the data-file header, and the docs now describe it as a scholarly ASCII (ISO 9-style) transliteration and warn it is not ISO 9-conformant. No behavior change.
  • #98docs/user-guide/transliteration.md no longer instructs users to pip install translit-rs[arabic|hebrew|context] (those empty extras were removed in 0.6.0); it now documents the bootstrap_dicts.sh / TRANSLIT_DICT_DIR path, matching the README and the runtime error message.
  • #99.1 / #99.2 — fixed two false docstrings: sort_key no longer claims to preserve accents (it folds them via transliteration, coinciding with search_key), and slugify no longer documents a pretranslate kwarg it never had.

  • #84 — corrected the README throughput table (Cyrillic ~106M chars/sec, slugify ~712K slugs/sec on commodity 4-vCPU hardware) and added a hardware/methodology footnote; added a matching variance note to docs/performance.md.

  • #77 — fixed the Text fluent-builder docstring example (normalize is keyword-only: .normalize(form="NFC")), reconciled the language-profile count (README now agrees with the docs at 83), and documented the context kwarg in the transliterate() docstring.

Internal / tests

  • #78 — added adversarial coverage for the raw-bytes decode path (detect_encoding / decode_to_utf8): deterministic hostile-byte cases in CI plus a Hypothesis st.binary() fuzz suite proving no-panic and invariant-preservation. Documented in THREAT_MODEL.md that the decode path has no input-size cap (caller's responsibility, per the 0.6.0 cap removal).
  • #79 — added a single-vs-batch kwarg parity regression test across the full kwarg matrix and a multi-script corpus (the tones batch drop fixed in 0.6.0 can no longer recur silently).

[0.6.0] — 2026-06-07

A hardening and bug-fix release. Two new opt-in helpers (dedup_batch, make_cached_transliterator) make this a minor bump; no public API was removed. Several fixes change output for specific inputs — read Upgrade notes before upgrading if you cache or persist transliterator/normalizer output.

Upgrade notes (output-affecting fixes)

Each of these was a bug; the new output is the correct one. If you store or cache results that were keyed on the old (buggy) behaviour, regenerate them:

  • register_replacements() now actually applies. It was a silent no-op — the registered table was never consulted. Registered replacements now take effect across transliterate() (scalar, list, and context=True). If you registered replacements and (knowingly or not) relied on them being ignored, output changes.
  • transliterate(list, tones=True) now returns toned pinyin (was silently toneless on the list path); transliterate(list, target=…, tones=True) now raises ValueError for the forward-only parameter (was silently ignored).
  • normalize_confusables(text, target="cyrillic") no longer maps characters onto invisible combining marks (28 such mappings removed).
  • strip_obfuscation now folds intra-Latin ASCII homoglyphs (þ→p, ſ→f, ı→i, …) and is idempotent; sanitize_user_input is idempotent for control/invisible characters between combining marks; demojize no longer inserts a stray space after a tab/newline that precedes an emoji.
  • Context-aware transliteration (context=True, ar/fa/he) distribution changed. The empty arabic/hebrew/context pip extras have been removed (they never installed anything). The ~37 MB dictionaries are no longer tracked in git, and are not shipped in the wheel. Context mode now loads dictionaries from $TRANSLIT_DICT_DIR (build them with scripts/bootstrap_dicts.sh), or use the embed-dicts Cargo feature for a self-contained build. A packaged pip-installable distribution is tracked in #56/#60.
  • decode_to_utf8 default min_confidence changed 0.00.5. Low-confidence encoding guesses are now rejected by default instead of silently accepted; pass min_confidence=0.0 to restore the old behaviour. (#66)
  • Unknown lang codes now raise instead of silently falling back (#68). A typo'd code (lang="RU", lang="russian") used to behave exactly like lang=None — quietly-wrong output — while errors=/form= rejected bad values. transliterate, slugify, sanitize_filename, catalog_key, search_key, sort_key, and ml_normalize now raise TranslitError listing the valid codes. "auto", the nb/nn/da aliases, and register_lang() codes are accepted. (target= already validated.)

Changed

  • No library-imposed input-size limit (#80, #65). The 10 MiB input cap on transliterate, normalize, fold_case, and the preset pipelines has been removed — it was paternalistic, inconsistently applied (the ASCII fast path bypassed it; slugify/normalize_confusables/strip_zalgo never had it), and the threat model already disclaims DoS. All operations are linear time and memory; bounding untrusted input is the caller's responsibility, documented in the threat model and docstrings. The single retained size guard is the register_replacements output amplification bound (a tiny input can expand to an enormous string via a caller-registered value — an amplification a caller's own input check cannot foresee). Backward-compatible: only previously-rejected large inputs now succeed.
  • External wording: capability, not promise. Security-relevant features are now described as mechanisms (TR39 confusable mapping, bidi/zalgo stripping, hostname analysis) rather than outcome guarantees. Package descriptions, README, and docs no longer claim to "prevent"/"neutralize" attacks or achieve "perfect" recovery; the XMR benchmark figure is always stated with its tested-pairs scope. Engineering rigor is held to a high internal bar (see below); the external surface promises nothing it cannot measure.

Added

  • dedup_batch(texts, …) — transliterate a list, processing each distinct value once and mapping back (large win for repeated/categorical data; ~146× on a high-locality column). Stateless — no cache to invalidate; unique values are chunked at the 100k batch cap. (#31)
  • make_cached_transliterator(maxsize=…, …) — opt-in LRU-cached single-string transliterator with options fixed at construction. Self-invalidating: the next call after any register_lang/register_replacements/remove_replacement/ clear_replacements clears the cache (via an internal table-generation counter), so it never serves stale results. Never enabled by default. (#31)
  • THREAT_MODEL.md — defines in-scope mechanisms, explicit out-of-scope items (confusables outside the bundled TR39 table, whole-script and multi-character confusables, Unicode-version skew, semantic attacks, DoS), and a vulnerability-vs- known-limitation policy, grounded in the literature (Holgers 2006, Deng 2020, BitAbuse 2025).
  • SECURITY.md rewritten on real footing: supported-version policy stated, triage scope defined, and linked to the threat model.
  • Security-invariant property tests + fuzzing. proptest invariants in Rust (src/presets.rs) assert no-panic, idempotence, and "no bidi/format control survives" for strip_obfuscation / security_clean / sanitize_user_input / strip_bidi across the Unicode input space; a deterministic, CI-gating adversarial attack-corpus regression (tests/test_attack_corpus.py: homoglyph / zalgo / invisible / bidi / combined, XMR-style); and a cargo-fuzz harness (fuzz/) for continuous coverage-guided fuzzing of the defense pipelines.
  • Confusable coverage for intra-Latin homoglyphs of basic ASCII letters (e.g. þ→p, ſ→f, ı→i, ƒ→f, Ɩ→l, ꜱ→s). The TR39 generator previously skipped all Latin-script sources for the Latin target, dropping ~83 genuine homoglyphs of A–Z/a–z; normalize_confusables/strip_obfuscation now fold them. Single-letter Latin confusable coverage of UTS#39 is now complete.
  • Pinned data/confusables.txt (UTS#39 17.0.0) as the reproducible, version- controlled input for scripts/gen_confusables.py (--download refreshes it), and a tests/test_confusable_coverage.py gate against Unicode-version drift.

Fixed

  • register_replacements() was a silent no-op — the global table was stored but never consulted by transliterate(). It now applies as a longest-match pre-pass (no cascade) across the scalar, list, and context=True forward paths, including ASCII-keyed replacements that previously bypassed Rust via the Python fast path. (#51)
  • tones= on the list/batch path was dropped: transliterate(["北京"], tones=True) returned toneless pinyin while the scalar path returned toned, and transliterate([...], target=…, tones=True) silently ignored the forward-only parameter instead of raising. Both now match the scalar path. (#14, #15)
  • normalize_confusables(target="cyrillic") emitted invisible combining marks — 28 mappings folded a visible character onto a combining Cyrillic-Extended mark (an obfuscation vector). The generator now excludes combining-mark targets. (#24)
  • script_info("CanadianAboriginal")["context_aware"] raised KeyError — the entry omitted a required ScriptMeta field; a completeness guard now prevents recurrence. (#18)
  • Context path skipped strict_iso9/gost7034 mutual-exclusion validationtransliterate(text, context=True, strict_iso9=True, gost7034=True) now raises ValueError like the non-context path; the missing-dictionary error hint is now language-specific (hehebrew). (#18)
  • demojize inserted a stray space after a tab/newline preceding an emoji ("a\t😀""a\t grinning face"); it now checks for any whitespace. (#12)
  • Compatibility digit variants fold to digits, not letters (#89). The confusables table mapped Mathematical Alphanumeric digits 𝟎/𝟏 (and the other four families, plus superscripts) to the look-alike letters O/l, so normalize_confusables("𝟏𝟎") gave "lO" and strip_obfuscation corrupted digit runs. The generator now folds any character whose NFKC form is an ASCII digit to that digit. They remain detected as confusable (is_confusable), but canonicalize to the correct number. (ASCII 0/1 were already unaffected.)
  • NFKC-compatible Latin is recovered instead of dropped to [?] (#81). Mathematical Alphanumeric Symbols (𝕳𝖊𝖑𝖑𝖔 𝟙𝟚𝟛Hello 123), presentation ligatures (/fi/fl), and superscripts (x2) now transliterate: an unmapped non-ASCII char is NFKC-decomposed and re-tried before the error fallback. This matches unidecode/anyascii and closes a filter-evasion ("fancy text") gap. Purely additive — only chars that were previously [?] are affected; emoji (no ASCII decomposition) still map to [?].
  • Defense pipelines are now idempotent (bugs found by the property tests):
  • strip_obfuscation: emoji whose CLDR name contains typographic punctuation (e.g. 👒woman’s hat, U+2019 ) weren't folded because confusables ran before demojize; a second pass folded '. Confusables now runs after demojize.
  • sanitize_user_input: an invisible or control character between combining marks (e.g. soft-hyphen, NUL) split a mark-run, so removing it after zalgo-capping merged runs that a second pass then capped differently. Bidi, zero-width, and control characters are now stripped before zalgo-capping.
  • Build-time and doc corrections: build.rs now rejects malformed \u{…} escapes in TSV data; embedded-dictionary parse errors are logged (not silently dropped); and numerous stale docstrings/comments were corrected (script_to_lang returns ISO 639-1 or 639-3; normalize() ASCII fast-path; list single-Rust-call caveats).

Security

  • seal_registrations() / registrations_sealed() (#64, high). The register_lang/register_replacements APIs mutate process-global tables consulted by every transliterate/slugify/catalog_key/… call, so in a multi-tenant or web process one import or request handler could silently alter everyone's canonicalization. seal_registrations() is a one-way latch: after it is called, register/remove/clear raise TranslitError. The registration APIs are now documented as startup-only/single-writer. Separately, a poisoned lock no longer resets registrations to defaults (a panic in one thread could previously wipe another caller's registered languages) — it now recovers the data as-is.
  • is_safe_hostname now decodes IDN/xn-- labels (#63, high). Previously an xn-- ACE label was pure ASCII → single-script → reported safe, so the on-the-wire form of the IDN homograph attack (a Cyrillic xn--80ak6aa92e.com "apple" spoof) sailed through — the exact blind spot for a library marketing idn/anti-spoofing. ACE labels are now UTS#46-decoded (via the idna crate) before script/confusable analysis; a malformed ACE label is treated as unsafe. Non-xn-- labels are untouched (no false positives on, e.g., my_host.local).
  • is_safe_hostname fails closed (#67.1). A confusable-check error no longer silently degrades to "not confusable" (unwrap_or(false)) → "safe"; it now marks the hostname unsafe.
  • strip_bidi/display_clean now also strip deprecated format controls (U+206A–U+206F) and interlinear annotation marks (U+FFF9–U+FFFB) (#67.2), which were previously only handled as transliteration-table entries.
  • NFKC×confusables composition pinned (#67.3). Added a regression test fixing the exact set of NFKC-ASCII results that normalize_confusables re-maps (`', "'', |l) so a data/ordering change — e.g. reintroducing digit→letter — fails loudly; and that presets resolve NFKC/TR39 conflicts (ſs) via NFKC.
  • Context dictionaries are no longer loaded from a CWD-relative path (#61). load_dict_from_fs previously probed ./data/{name}_dict.bin first, so a process whose working directory an attacker influences (or where they can drop ./data/) could inject a substitute dictionary and silently change ar/fa/he output. Dictionaries now load only from $TRANSLIT_DICT_DIR (explicit opt-in) or the crate's own absolute data/ path in source builds.
  • Supply-chain: corpus inputs are verified/pinned (#62). The Tashkeela corpus archive is now checksum-verified before it feeds the builders (fail-closed — an unpinned checksum aborts unless ALLOW_UNVERIFIED_CORPUS=1), and the Project Ben Yehuda corpus is fetched at a pinned commit instead of an unpinned live HEAD.
  • ContextDict::from_bytes is fully bounds-checked. A malformed or truncated context dictionary previously caused an out-of-bounds panic (the crate is unsafe_code = forbid, so a panic aborts the process). Every read is now bounds-checked and section offsets are validated; capacity hints are clamped. Added truncation/bogus-offset/u32::MAX-count unit tests. (#18)
  • register_replacements expansion is bounded. Replacement values are caller-controlled and unbounded; a small input with a large value could expand past the transliterate input cap. Output is now bounded during construction and rejected once it would exceed MAX_TRANSLITERATE_INPUT_BYTES. (#51)

Internal / tests

  • 170 deterministic tests were excluded from CI. A module-level pytestmark = pytest.mark.hypothesis in test_filename_regressions.py and test_case_folding.py (filename-security and case-folding regressions) deselected the entire files under CI's -m "not hypothesis" filter; only ~10 were actual property tests. The mark is now scoped to the property-test class in each file, so the deterministic tests run in CI. (#12)
  • New tests: register_replacements (unit + Hypothesis property), context-dict parser robustness, resolve_auto_lang for all 18 scripts added in v0.3.0+, and a SCRIPT_META field-completeness guard.
  • CI/workflow hygiene: concurrency group on secret-scan, uv.lock in the benchmark path filter, and CodeQL no longer triggered by Rust-only changes.