use unicode_normalization::char::{canonical_combining_class, is_combining_mark};
use unicode_normalization::UnicodeNormalization;
pub(crate) const DEFAULT_THRESHOLD: usize = 3;
pub(crate) const DEFAULT_MAX_MARKS: usize = DEFAULT_THRESHOLD;
fn exceeds_combining_run(text: &str, threshold: usize) -> bool {
let mut run: usize = 0;
let mut previous: u8 = 0;
for ch in text.nfd() {
if is_combining_mark(ch) {
let class = canonical_combining_class(ch);
if class == 0 && threshold > 0 {
run = 0;
previous = 0;
continue;
}
run = if class == previous { run + 1 } else { 1 };
previous = class;
if run > threshold {
return true;
}
} else {
run = 0;
previous = 0;
}
}
false
}
pub(crate) fn is_zalgo(text: &str, threshold: usize) -> bool {
if text.is_ascii() {
return false;
}
exceeds_combining_run(text, threshold)
}
pub(crate) fn drop_repeated_marks_into(text: &str, out: &mut String) -> bool {
if !has_repeated_mark(text) {
return false;
}
out.clear();
let mut filtered = String::with_capacity(text.len());
let mut previous: Option<char> = None;
for ch in text.nfd() {
if is_combining_mark(ch) && canonical_combining_class(ch) != 0 {
if previous == Some(ch) {
continue;
}
previous = Some(ch);
} else {
previous = None;
}
filtered.push(ch);
}
out.extend(filtered.nfc());
true
}
fn has_repeated_mark(text: &str) -> bool {
let mut previous: Option<char> = None;
for ch in text.nfd() {
if is_combining_mark(ch) && canonical_combining_class(ch) != 0 {
if previous == Some(ch) {
return true;
}
previous = Some(ch);
} else {
previous = None;
}
}
false
}
pub(crate) fn strip_zalgo(text: &str, max_marks: usize) -> String {
let mut out = String::new();
strip_zalgo_into(text, max_marks, &mut out);
out
}
pub(crate) fn strip_zalgo_into(text: &str, max_marks: usize, out: &mut String) {
out.clear();
if text.is_ascii() {
out.push_str(text);
return;
}
if !exceeds_combining_run(text, max_marks) {
out.extend(text.nfc());
return;
}
let mut filtered = String::with_capacity(text.len());
let mut mark_count: usize = 0;
let mut mark_class: u8 = 0;
let mut base: Option<char> = None;
let mut negation_kept = false;
for ch in text.nfd() {
if crate::transliterate::is_negation_of(ch, base) && !negation_kept {
negation_kept = true;
filtered.push(ch);
} else if is_combining_mark(ch) {
let class = canonical_combining_class(ch);
if class == 0 && max_marks > 0 {
mark_count = 0;
mark_class = 0;
filtered.push(ch);
} else {
mark_count = if class == mark_class {
mark_count + 1
} else {
1
};
mark_class = class;
if mark_count <= max_marks {
filtered.push(ch);
}
}
} else {
mark_count = 0;
mark_class = 0;
negation_kept = false;
base = Some(ch);
filtered.push(ch);
}
}
out.extend(filtered.nfc());
}
pub(crate) fn strip_cross_script_marks_into(text: &str, out: &mut String) {
out.clear();
out.reserve(text.len());
let mut base_script: Option<&'static str> = None;
for ch in text.chars() {
if is_combining_mark(ch) {
let mark_script = crate::scripts::detect_char_script(ch);
let specific = mark_script != "Inherited" && mark_script != "Common";
if specific && base_script.is_some_and(|b| b != mark_script) {
continue; }
out.push(ch);
continue;
}
base_script = match crate::scripts::detect_char_script(ch) {
"Common" | "Inherited" => base_script,
s => Some(s),
};
out.push(ch);
}
}
#[cfg(test)]
mod tests {
#[test]
fn zero_max_marks_strips_class_zero_marks_too() {
for (mark, name) in [
('\u{0E31}', "THAI CHARACTER MAI HAN-AKAT"),
('\u{093F}', "DEVANAGARI VOWEL SIGN I"),
('\u{102D}', "MYANMAR VOWEL SIGN I"),
('\u{09BE}', "BENGALI VOWEL SIGN AA"),
] {
assert_eq!(
canonical_combining_class(mark),
0,
"{name} is no longer class 0; pick another example",
);
let input = format!("\u{0E01}{mark}");
let stripped = strip_zalgo(&input, 0);
assert!(
!stripped.contains(mark),
"max_marks=0 left {name} in {stripped:?}",
);
assert!(
is_zalgo(&input, 0),
"threshold 0 must call {name} excess, to match what strip_zalgo removes",
);
}
}
#[test]
fn nonzero_max_marks_still_exempts_class_zero_marks() {
let burmese = "\u{1019}\u{103C}\u{102D}\u{102F}\u{1037}";
assert_eq!(strip_zalgo(burmese, DEFAULT_MAX_MARKS), burmese);
assert!(!is_zalgo(burmese, DEFAULT_MAX_MARKS));
}
use super::*;
#[test]
fn test_is_zalgo_clean_text() {
assert!(!is_zalgo("hello world", 3));
assert!(!is_zalgo("café résumé", 3));
assert!(!is_zalgo("", 3));
}
#[test]
fn test_is_zalgo_ascii_fast_path() {
assert!(!is_zalgo("just ascii text 12345!@#$%", 3));
}
#[test]
fn test_is_zalgo_vietnamese() {
assert!(!is_zalgo("Việt Nam", 3));
assert!(!is_zalgo("ệ", 2));
}
#[test]
fn test_is_zalgo_detects_stacking() {
let mut zalgo = String::from("a");
for _ in 0..10 {
zalgo.push('\u{0300}'); }
assert!(is_zalgo(&zalgo, 3));
}
#[test]
fn test_is_zalgo_threshold_boundary() {
let mut text = String::from("a");
for _ in 0..3 {
text.push('\u{0300}');
}
assert!(!is_zalgo(&text, 3));
text.push('\u{0300}');
assert!(is_zalgo(&text, 3));
}
#[test]
fn test_strip_zalgo_clean_text_unchanged() {
assert_eq!(strip_zalgo("hello world", 2), "hello world");
assert_eq!(strip_zalgo("café", 2), "café");
}
#[test]
fn test_strip_zalgo_preserves_legitimate_diacritics() {
let input = "Việt Nam";
assert_eq!(strip_zalgo(input, 2), input);
assert_eq!(strip_zalgo("résumé", 2), "résumé");
}
#[test]
fn test_strip_zalgo_removes_excess() {
let mut zalgo = String::from("a");
for _ in 0..10 {
zalgo.push('\u{0300}'); }
let result = strip_zalgo(&zalgo, 2);
assert!(result.chars().count() <= 3); assert!(result.starts_with('à'));
}
#[test]
fn test_strip_zalgo_max_marks_zero_strips_all() {
assert_eq!(strip_zalgo("café", 0), "cafe");
assert_eq!(strip_zalgo("résumé", 0), "resume");
}
#[test]
fn test_strip_zalgo_ascii_fast_path() {
let input = "just ascii";
assert_eq!(strip_zalgo(input, 2), input);
}
#[test]
fn test_strip_zalgo_multiple_base_chars() {
let mut zalgo = String::new();
for base in ['H', 'i'] {
zalgo.push(base);
for _ in 0..8 {
zalgo.push('\u{0300}');
zalgo.push('\u{0301}');
zalgo.push('\u{0302}');
}
}
let result = strip_zalgo(&zalgo, 2);
let mut mark_count = 0;
for ch in result.nfd() {
if is_combining_mark(ch) {
mark_count += 1;
assert!(mark_count <= 2, "Too many combining marks in output");
} else {
mark_count = 0;
}
}
}
#[test]
fn test_exceeds_combining_run() {
assert!(!exceeds_combining_run("hello", 0));
assert!(!exceeds_combining_run("café", 1)); assert!(!exceeds_combining_run("", 0));
let mut text = String::from("a");
for _ in 0..5 {
text.push('\u{0300}');
}
assert!(exceeds_combining_run(&text, 2)); assert!(!exceeds_combining_run(&text, 5)); }
proptest::proptest! {
#[test]
fn strip_zalgo_output_is_nfc(s in "\\PC*", max in 0usize..4) {
let out = strip_zalgo(&s, max);
proptest::prop_assert!(unicode_normalization::is_nfc(&out));
}
#[test]
fn strip_zalgo_fast_path_matches_filter(s in "\\PC*") {
let out = strip_zalgo(&s, 1000);
let nfc: String = s.nfc().collect();
proptest::prop_assert_eq!(out, nfc);
}
}
}