use crate::types::{CharHit, CleanStats, Confidence, TextInspectReport, WatermarkKind};
use std::collections::BTreeMap;
pub const MAX_TEXT_CHARS: usize = 64 * 1024 * 1024;
pub const STRIP_CODEPOINTS: &[u32] = &[
0x00AD, 0x034F, 0x061C, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180C, 0x180D, 0x180E, 0x200B, 0x200C, 0x200D, 0x200E, 0x200F, 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2060, 0x2061, 0x2062, 0x2063, 0x2064, 0x2066, 0x2067, 0x2068, 0x2069, 0x206A, 0x206B, 0x206C, 0x206D, 0x206E, 0x206F, 0xFEFF, 0xFE00, 0xFE01, 0xFE02, 0xFE03, 0xFE04, 0xFE05, 0xFE06, 0xFE07, 0xFE08, 0xFE09, 0xFE0A, 0xFE0B, 0xFE0C,
0xFE0D, 0xFE0E, 0xFE0F, 0xFFF9, 0xFFFA, 0xFFFB, ];
pub const SPACE_HOMOGLYPHS: &[(u32, char)] = &[
(0x00A0, ' '), (0x1680, ' '), (0x2000, ' '), (0x2001, ' '), (0x2002, ' '), (0x2003, ' '), (0x2004, ' '), (0x2005, ' '), (0x2006, ' '), (0x2007, ' '), (0x2008, ' '), (0x2009, ' '), (0x200A, ' '), (0x202F, ' '), (0x205F, ' '), (0x3000, ' '), ];
pub const LATIN_CONFUSABLES: &[(u32, char)] = &[
(0x0410, 'A'), (0x0412, 'B'), (0x0415, 'E'), (0x041A, 'K'), (0x041C, 'M'), (0x041D, 'H'), (0x041E, 'O'), (0x0420, 'P'), (0x0421, 'C'), (0x0422, 'T'), (0x0425, 'X'), (0x0430, 'a'), (0x0435, 'e'), (0x043E, 'o'), (0x0440, 'p'), (0x0441, 'c'), (0x0443, 'y'), (0x0445, 'x'), (0x0456, 'i'), (0xFF21, 'A'),
(0xFF22, 'B'),
(0xFF23, 'C'),
(0xFF24, 'D'),
(0xFF25, 'E'),
(0xFF26, 'F'),
(0xFF27, 'G'),
(0xFF28, 'H'),
(0xFF29, 'I'),
(0xFF2A, 'J'),
(0xFF2B, 'K'),
(0xFF2C, 'L'),
(0xFF2D, 'M'),
(0xFF2E, 'N'),
(0xFF2F, 'O'),
(0xFF30, 'P'),
(0xFF31, 'Q'),
(0xFF32, 'R'),
(0xFF33, 'S'),
(0xFF34, 'T'),
(0xFF35, 'U'),
(0xFF36, 'V'),
(0xFF37, 'W'),
(0xFF38, 'X'),
(0xFF39, 'Y'),
(0xFF3A, 'Z'),
(0xFF41, 'a'),
(0xFF42, 'b'),
(0xFF43, 'c'),
(0xFF44, 'd'),
(0xFF45, 'e'),
(0xFF46, 'f'),
(0xFF47, 'g'),
(0xFF48, 'h'),
(0xFF49, 'i'),
(0xFF4A, 'j'),
(0xFF4B, 'k'),
(0xFF4C, 'l'),
(0xFF4D, 'm'),
(0xFF4E, 'n'),
(0xFF4F, 'o'),
(0xFF50, 'p'),
(0xFF51, 'q'),
(0xFF52, 'r'),
(0xFF53, 's'),
(0xFF54, 't'),
(0xFF55, 'u'),
(0xFF56, 'v'),
(0xFF57, 'w'),
(0xFF58, 'x'),
(0xFF59, 'y'),
(0xFF5A, 'z'),
];
const BIDI_CODEPOINTS: &[u32] = &[
0x061C, 0x200E, 0x200F, 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069,
];
const ZW_FAMILY: &[u32] = &[0x200B, 0x200C, 0x200D, 0x2060, 0xFEFF, 0x180E];
const ORTHOGRAPHIC_CF: &[u32] = &[
0x0600, 0x0601, 0x0602, 0x0603, 0x0604, 0x0605, 0x06DD, 0x070F, 0x08E2, 0x110BD, 0x110CD,
];
const EMOJI_GLUE: &[u32] = &[0x200D, 0xFE0E, 0xFE0F];
const SCRIPT_JOINERS: &[u32] = &[0x200C, 0x200D];
const MONGOLIAN_FVS: &[u32] = &[0x180B, 0x180C, 0x180D];
const KHMER_VOWELS: &[u32] = &[0x17B4, 0x17B5];
const HANGUL_FILLERS: &[u32] = &[0x115F, 0x1160];
#[derive(Debug, Clone, Default)]
pub struct InspectOpts {
pub aggressive_confusables: bool,
pub strip_emoji_glue: bool,
}
#[derive(Debug, Clone, Default)]
pub struct CleanOpts {
pub normalize_spaces: bool,
pub aggressive_confusables: bool,
pub nfkc: bool,
pub strip_emoji_glue: bool,
}
impl CleanOpts {
pub fn safe() -> Self {
Self {
normalize_spaces: true,
aggressive_confusables: false,
nfkc: false,
strip_emoji_glue: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Decision {
Keep,
Strip,
Replace(char),
}
fn is_private_use(cp: u32) -> bool {
(0xE000..=0xF8FF).contains(&cp)
|| (0xF0000..=0xFFFFD).contains(&cp)
|| (0x100000..=0x10FFFD).contains(&cp)
}
fn is_strip_cp(cp: u32) -> bool {
STRIP_CODEPOINTS.contains(&cp)
|| (0xE0100..=0xE01EF).contains(&cp) || (0xE0001..=0xE007F).contains(&cp) || is_private_use(cp)
}
fn strip_kind(cp: u32) -> WatermarkKind {
if (0xE0001..=0xE007F).contains(&cp) {
return WatermarkKind::TagChar;
}
if (0xE0100..=0xE01EF).contains(&cp)
|| (0xFE00..=0xFE0F).contains(&cp)
|| MONGOLIAN_FVS.contains(&cp)
{
return WatermarkKind::VariationSelector;
}
if BIDI_CODEPOINTS.contains(&cp) {
return WatermarkKind::Bidi;
}
if ZW_FAMILY.contains(&cp) {
return WatermarkKind::ZwjFamily;
}
if is_private_use(cp) {
return WatermarkKind::PrivateUse;
}
WatermarkKind::UnicodeCarrier
}
fn is_emoji_base(cp: u32) -> bool {
(0x1F000..=0x1FAFF).contains(&cp)
|| (0x2600..=0x27BF).contains(&cp)
|| (0x2B00..=0x2BFF).contains(&cp)
|| matches!(
cp,
0x00A9 | 0x00AE | 0x2122 | 0x3030 | 0x303D | 0x3297 | 0x3299
)
|| ((0x0030..=0x0039).contains(&cp) || matches!(cp, 0x0023 | 0x002A))
}
fn is_joining_letter(cp: u32) -> bool {
if cp <= 0x7F {
return false;
}
matches!(
unicode_general_category::get_general_category(char::from_u32(cp).unwrap_or('\u{FFFD}')),
unicode_general_category::GeneralCategory::LowercaseLetter
| unicode_general_category::GeneralCategory::UppercaseLetter
| unicode_general_category::GeneralCategory::TitlecaseLetter
| unicode_general_category::GeneralCategory::OtherLetter
| unicode_general_category::GeneralCategory::ModifierLetter
| unicode_general_category::GeneralCategory::NonspacingMark
| unicode_general_category::GeneralCategory::SpacingMark
| unicode_general_category::GeneralCategory::EnclosingMark
)
}
fn is_mongolian_letter(cp: u32) -> bool {
(0x1800..=0x18AF).contains(&cp)
&& matches!(
unicode_general_category::get_general_category(
char::from_u32(cp).unwrap_or('\u{FFFD}')
),
unicode_general_category::GeneralCategory::LowercaseLetter
| unicode_general_category::GeneralCategory::UppercaseLetter
| unicode_general_category::GeneralCategory::OtherLetter
)
}
fn is_khmer_letter(cp: u32) -> bool {
(0x1780..=0x17FF).contains(&cp)
&& matches!(
unicode_general_category::get_general_category(
char::from_u32(cp).unwrap_or('\u{FFFD}')
),
unicode_general_category::GeneralCategory::LowercaseLetter
| unicode_general_category::GeneralCategory::OtherLetter
)
}
fn is_hangul_jamo(cp: u32) -> bool {
(0x1100..=0x11FF).contains(&cp)
|| (0xA960..=0xA97C).contains(&cp)
|| (0xD7B0..=0xD7C6).contains(&cp)
}
fn is_glue(cp: u32) -> bool {
EMOJI_GLUE.contains(&cp)
|| SCRIPT_JOINERS.contains(&cp)
|| (0xE0020..=0xE007F).contains(&cp)
|| MONGOLIAN_FVS.contains(&cp)
|| KHMER_VOWELS.contains(&cp)
|| HANGUL_FILLERS.contains(&cp)
}
fn decide(
ch: char,
prev_kept: Option<char>,
opts: &CleanOpts,
) -> (Decision, Option<WatermarkKind>) {
let cp = ch as u32;
if !opts.strip_emoji_glue {
if EMOJI_GLUE.contains(&cp) && prev_kept.is_some_and(|prev| is_emoji_base(prev as u32)) {
return (Decision::Keep, None);
}
if SCRIPT_JOINERS.contains(&cp)
&& prev_kept.is_some_and(|prev| is_joining_letter(prev as u32))
{
return (Decision::Keep, None);
}
if (0xE0020..=0xE007F).contains(&cp)
&& prev_kept.is_some_and(|prev| is_emoji_base(prev as u32))
{
return (Decision::Keep, None);
}
if MONGOLIAN_FVS.contains(&cp)
&& prev_kept.is_some_and(|prev| is_mongolian_letter(prev as u32))
{
return (Decision::Keep, None);
}
if KHMER_VOWELS.contains(&cp) && prev_kept.is_some_and(|prev| is_khmer_letter(prev as u32))
{
return (Decision::Keep, None);
}
if HANGUL_FILLERS.contains(&cp) && prev_kept.is_some_and(|prev| is_hangul_jamo(prev as u32))
{
return (Decision::Keep, None);
}
if ORTHOGRAPHIC_CF.contains(&cp) {
return (Decision::Keep, None);
}
}
if is_strip_cp(cp) {
return (Decision::Strip, Some(strip_kind(cp)));
}
if opts.normalize_spaces {
for &(homoglyph, replacement) in SPACE_HOMOGLYPHS {
if cp == homoglyph {
return (
Decision::Replace(replacement),
Some(WatermarkKind::SpaceHomoglyph),
);
}
}
}
if opts.aggressive_confusables {
for &(confusable, replacement) in LATIN_CONFUSABLES {
if cp == confusable {
return (
Decision::Replace(replacement),
Some(WatermarkKind::LatinConfusable),
);
}
}
}
let cat = unicode_general_category::get_general_category(ch);
if cat == unicode_general_category::GeneralCategory::Format {
let is_space_homoglyph = SPACE_HOMOGLYPHS.iter().any(|&(c, _)| c == cp);
if !is_space_homoglyph {
return (Decision::Strip, Some(WatermarkKind::UnicodeCarrier));
}
}
(Decision::Keep, None)
}
fn char_label(cp: u32) -> String {
if let Some(ch) = char::from_u32(cp) {
let name = unicode_names2::name(ch)
.map(|n| n.to_string())
.unwrap_or_else(|| "UNKNOWN".to_string());
let cat = unicode_general_category::get_general_category(ch);
format!("U+{cp:04X} {name} ({cat:?})")
} else {
format!("U+{cp:04X} INVALID_CODEPOINT")
}
}
fn hit_confidence(kind: &WatermarkKind) -> Confidence {
match kind {
WatermarkKind::SpaceHomoglyph => Confidence::Informational,
_ => Confidence::Probable,
}
}
pub fn inspect_text(text: &str, opts: &InspectOpts) -> crate::error::Result<TextInspectReport> {
if text.chars().count() > MAX_TEXT_CHARS {
return Err(crate::error::CumError::InputTooLarge {
limit: MAX_TEXT_CHARS,
actual: text.chars().count(),
});
}
let clean_opts = CleanOpts {
normalize_spaces: true,
aggressive_confusables: opts.aggressive_confusables,
nfkc: false,
strip_emoji_glue: opts.strip_emoji_glue,
};
let mut buckets: BTreeMap<(u32, String), Vec<usize>> = BTreeMap::new();
let mut prev_kept: Option<char> = None;
for (char_offset, ch) in text.chars().enumerate() {
let (decision, kind_opt) = decide(ch, prev_kept, &clean_opts);
if let Some(kind) = kind_opt {
let key = (ch as u32, kind.as_str().to_string());
buckets.entry(key).or_default().push(char_offset);
if let Decision::Replace(r) = decision
&& !is_glue(r as u32)
{
prev_kept = Some(r);
}
} else if matches!(decision, Decision::Keep) && !is_glue(ch as u32) {
prev_kept = Some(ch);
}
}
let mut hits: Vec<CharHit> = buckets
.into_iter()
.map(|((cp, kind_str), offsets)| {
let kind = kind_from_str(&kind_str);
let confidence = hit_confidence(&kind);
let label = char_label(cp);
let count = offsets.len();
let sample_offsets = offsets.into_iter().take(10).collect();
CharHit {
codepoint: cp,
character: char::from_u32(cp)
.map(|c| c.to_string())
.unwrap_or_default(),
label,
count,
kind,
confidence,
sample_offsets,
}
})
.collect();
hits.sort_by(|a, b| b.count.cmp(&a.count).then(a.codepoint.cmp(&b.codepoint)));
let suspicious_total = hits.iter().map(|h| h.count).sum();
let length = text.chars().count();
let mut notes = vec![
"Layer A only: invisible/format Unicode and space homoglyphs (edit-based carriers).".into(),
"Statistical (token-sampling) watermarks are not detectable here; use Layer B rewrite.".into(),
"Inspect kinds: zwj_family, bidi, tag_chars, variation_selector, private_use, space_homoglyph, latin_confusable, unicode_carrier.".into(),
"Load-bearing invisibles are preserved by default: emoji glue (ZWJ/VS after emoji base), script joiners (ZWNJ/ZWJ inside complex scripts), flag tag chars, Mongolian FVS, Khmer inherent vowels, Hangul jamo fillers, orthographic Arabic/Syriac Cf marks.".into(),
];
if hits.is_empty() {
notes.push("No deterministic Layer A (invisible Unicode/format) carriers detected.".into());
}
Ok(TextInspectReport {
length,
suspicious_total,
hits,
notes,
})
}
pub fn clean_text(text: &str, opts: &CleanOpts) -> crate::error::Result<(String, CleanStats)> {
if text.chars().count() > MAX_TEXT_CHARS {
return Err(crate::error::CumError::InputTooLarge {
limit: MAX_TEXT_CHARS,
actual: text.chars().count(),
});
}
let mut out = String::with_capacity(text.len());
let mut prev_kept: Option<char> = None;
let mut removed_count: usize = 0;
let mut replaced_count: usize = 0;
let mut summary_items: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for ch in text.chars() {
let (decision, kind_opt) = decide(ch, prev_kept, opts);
match decision {
Decision::Keep => {
out.push(ch);
if !is_glue(ch as u32) {
prev_kept = Some(ch);
}
}
Decision::Strip => {
removed_count += 1;
if let Some(kind) = kind_opt {
*summary_items.entry(kind.as_str().to_string()).or_insert(0) += 1;
}
}
Decision::Replace(replacement) => {
out.push(replacement);
replaced_count += 1;
if let Some(kind) = kind_opt {
*summary_items.entry(kind.as_str().to_string()).or_insert(0) += 1;
}
if !is_glue(replacement as u32) {
prev_kept = Some(replacement);
}
}
}
}
if opts.nfkc {
let before_len = out.len();
let normalized =
unicode_normalization::UnicodeNormalization::nfkc(&*out).collect::<String>();
if normalized.len() != before_len {
replaced_count += normalized.len().abs_diff(before_len).max(1);
}
out = normalized;
}
let summary: Vec<String> = summary_items
.iter()
.map(|(k, v)| format!("{k}: {v}"))
.collect();
Ok((
out,
CleanStats {
removed_count,
replaced_count,
metadata_chunks_removed: 0,
summary,
},
))
}
fn kind_from_str(s: &str) -> WatermarkKind {
match s {
"space_homoglyph" => WatermarkKind::SpaceHomoglyph,
"latin_confusable" => WatermarkKind::LatinConfusable,
"tag_chars" => WatermarkKind::TagChar,
"variation_selector" => WatermarkKind::VariationSelector,
"bidi" => WatermarkKind::Bidi,
"zwj_family" => WatermarkKind::ZwjFamily,
"private_use" => WatermarkKind::PrivateUse,
_ => WatermarkKind::UnicodeCarrier,
}
}