disarm

Combining marks

Detect zalgo text without breaking Vietnamese

Paste text to see how deep its combining marks stack, base character by base character. The naive rule — strip the marks, or allow only one — quietly corrupts Vietnamese, which needs two on a single letter.

The tool

Four lines: Vietnamese and French, which must survive any sane rule; a zalgo with two marks, which no threshold separates from the Vietnamese; and a sprawling one, which every threshold catches.

Paste text to inspect its marks.

Nothing is uploaded. The engine runs inside this page.

Capped at two marks

strip_zalgo with disarm's default cap, which is what Vietnamese needs.


      

Loading the engine…

Where depth stops working

Mark depth is the only thing a counter can see, and it has a floor. These two are the same shape:

TextDecomposes toMarks
ế — VietnameseU+0065 U+0302 U+03012
Z͓̎ — zalgoU+005A U+030E U+03532

A base and two marks in both cases, so no threshold tells them apart. Depth catches the sprawling kind reliably and the restrained kind not at all. Separating those needs to look at which marks appear and whether they form a real orthographic unit — a different question from how many.

What depth does buy you is a safe floor. Measured against disarm, with the deepest stack each sample reaches:

SampleDeepest stackFlagged at threshold 1?At 3?
Vietnamese — Tiếng Việt2yesno
Hebrew with niqqud2yesno
French — café naïve1nono
Thai1nono
Zalgo, sprawling5+yesyes

A threshold of one rejects ordinary Vietnamese and Hebrew. Three is disarm's default for detection, and two is its default when capping, because two is what Vietnamese needs.

The same thing in your own code

Each block is a file CI compiles and runs, so none can quietly stop working, and all six print the same line. There is no C here: the C ABI exposes no is_zalgo or strip_zalgo, so there is nothing to call. disarm on GitHub →

# Cap combining-mark stacking without corrupting Vietnamese.
#   pip install disarm
from disarm import is_zalgo, strip_zalgo

# Vietnamese puts two marks on one base: ế is U+0065 U+0302 U+0301.
VIETNAMESE = "Tiếng Việt"
# Five marks on one base, which no writing system uses.
ZALGO = "Hͤͥͦͧͨ"

# The naive rule — at most one mark — rejects an ordinary Vietnamese word.
assert is_zalgo(VIETNAMESE, threshold=1), "a threshold of 1 rejects Vietnamese"
assert not is_zalgo(VIETNAMESE, threshold=3), "the default does not"
assert is_zalgo(ZALGO, threshold=3), "and still catches sprawling zalgo"

# Capping at two is what leaves Vietnamese untouched.
assert strip_zalgo(VIETNAMESE, max_marks=2) == VIETNAMESE
assert strip_zalgo(ZALGO, max_marks=2) != ZALGO

print("ok: threshold 1 rejects Vietnamese, threshold 3 does not, zalgo caught either way")