vi-textmatch
Vietnamese text normalization and type-aware comparison. Zero dependencies, standard library only.
Built for document verification — checking that a drafted contract matches the source record — where "close enough" is the right answer for a person's name and a catastrophically wrong answer for an ID number.
The problem
Two strings that look identical on screen are often not equal in memory:
"Huệ" == "Huệ" # False
One is NFC (ệ = 1 codepoint), the other NFD (e + combining dot below + combining circumflex = 3).
macOS filenames are NFD; most other sources are NFC.
And Vietnamese tone marks have two accepted positions in open syllables:
"hoà" == "hòa" # False — same word, different bytes
Meanwhile, applying a naive oà → òa rewrite corrupts closed syllables:
hoàn → hòan, suýt → súyt, huỳnh → hùynh — none of which are Vietnamese words.
Worse, the corruption creates false matches between different words.
Install
pip install vi-textmatch
Python 3.11+. No third-party dependencies.
Usage
from vi_textmatch import normalize, compare, Kind
normalize("Nguyễn Thị Hoà ") # 'nguyễn thị hòa'
normalize("hoàn") # 'hoàn' — closed syllable, untouched
normalize("suýt") # 'suýt' — closed syllable, untouched
compare("Nguyễn Thị Hòa", "nguyễn thị hoà", kind=Kind.TEXT)
# Verdict(match=True, level='IDENTICAL')
compare("Nguyễn Thị Hòa", "Nguyễn Thị Hoa", kind=Kind.TEXT)
# Verdict(match=True, level='TYPO') — one character off, likely OCR/typing
compare("Nguyễn Thị Hòa", "Trần Văn Bình", kind=Kind.TEXT)
# Verdict(match=False, level='DIFFERENT') — different person
compare("031234567890", "031234567891", kind=Kind.ID)
# Verdict(match=False) — one digit apart is a different person, not a typo
compare("2.500.000.000", "2,500,000,000", kind=Kind.MONEY) # match
compare("2.500.000.000", "2.600.000.000", kind=Kind.MONEY) # no match
kind is keyword-only and has no default. Calling compare(a, b) raises TypeError
rather than silently falling back to fuzzy text matching.
Comparison kinds
Kind |
Method | One character apart means |
|---|---|---|
TEXT |
normalize, then difflib ratio |
possibly a typo — graded, see below |
ID |
strip separators, then exact equality | a different entity |
MONEY |
parse to Decimal, compare values |
a different amount |
DATE |
parse to date, compare values |
a different date |
TEXT grades the difference so you can act on it:
| ratio | level | Interpretation |
|---|---|---|
| 1.0 | IDENTICAL |
same string after normalization |
| ≥ 0.85 | TYPO |
likely a typing or OCR error — fix the text |
| < 0.85 | DIFFERENT |
likely the wrong record — stop and re-check |
Similarity is meaningless for identifiers and amounts, so ID, MONEY and DATE never use it.
MONEY and DATE raise on input they cannot parse; they never silently degrade to string comparison.
Normalization pipeline
normalize() applies four steps, in this order:
- NFC — compose combining marks. Must come first: step 2 matches single codepoints.
- Tone placement — rewrite
hoà → hòaonly in open syllables. - Whitespace — strip nbsp/zero-width, collapse runs, trim.
- casefold — must come last: step 2 needs the original case to preserve it.
Each step is also exported individually.
Tone placement rule
The tone mark sits on the syllable nucleus. Two written forms exist only for oa, oe, uy
in an open syllable (nothing follows the pair). In a closed syllable there is exactly one
correct form and it must never be rewritten:
| Rewritten | Left alone | |
|---|---|---|
| open | hoà → hòa, thuỷ → thủy, hoè → hòe |
— |
| closed | — | hoàn, suýt, huỳnh, khuỷu, khoét, huých |
| pre-accented vowel (ê ô ơ ă â ư) | — | huệ, thuở, chuồn, thuyền, giường |
qu / gi initial cluster |
— | quý, quỳnh, quán, già, giữ |
This library deliberately avoids the labels "old style" and "new style": published sources use them in opposite directions. Positions are named explicitly instead.
See research/QUY-TAC.md for the derivation and sources.
Scope
This library knows orthography, not vocabulary.
It does: normalize spelling variants, compare by data type, grade differences.
It does not: know whether a string is a real Vietnamese word, find names inside text (that is NER — see underthesea), correct spelling, or understand meaning.
Development
PYTHONPATH=src python3 -m unittest discover tests
License
MIT
Metadata
Release files for vi-textmatch 0.2.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 | |
|---|---|---|---|
| vi_textmatch-0.2.0.tar.gz | 20.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| vi_textmatch-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 34.7 kB
Release files / vi_textmatch-0.2.0.tar.gz
| Download URL | vi_textmatch-0.2.0.tar.gz |
|---|---|
| Size | 20.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d0201c3b0f10f652d0171a49a01d321f5753d712d538312b02902576769761f3
|
|
BLAKE2b-256 checksum How to use checksums |
264c250d2ff02407a9dd4e24c52ecae3e8be6681a2358fcb2ddca5ca359896b5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / vi_textmatch-0.2.0-py3-none-any.whl
| Download URL | vi_textmatch-0.2.0-py3-none-any.whl |
|---|---|
| Size | 14.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1210b180b1d6e95557d2d65ee9d91a688b187000e57a597860cd45ee7a093369
|
|
BLAKE2b-256 checksum How to use checksums |
7ab3bbe5bec4b620f8d3490bfcd62aa0427139557e4491ef033ce58c58bd4268
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|