use unicode_normalization::UnicodeNormalization;
use unicode_script::{Script, UnicodeScript};
pub fn letter_latin(cp: u32) -> Option<char> {
let c = char::from_u32(cp)?;
if c.is_alphabetic() && c.script() == Script::Latin {
c.to_lowercase().next()
} else {
None
}
}
pub fn skel(c: char) -> String {
unicode_security::confusable_detection::skeleton(&c.to_string()).collect()
}
pub fn legitimately_identical(a: char, b: char) -> bool {
if a == b {
return true;
}
if a.script() != b.script() {
return true;
}
if a.to_string().nfkd().eq(b.to_string().nfkd()) {
return true;
}
let (da, db): (String, String) =
(a.to_string().nfkd().collect(), b.to_string().nfkd().collect());
let (na, nb) = (da.chars().count(), db.chars().count());
if (na > 1 && nb == 1 && da.contains(&db)) || (nb > 1 && na == 1 && db.contains(&da)) {
return true;
}
if let (Some(x), Some(y)) = (deunicode::deunicode_char(a), deunicode::deunicode_char(b)) {
if !x.is_empty() && x == y {
return true;
}
}
let mut p = [a, b];
p.sort_unstable();
if matches!((p[0], p[1]), ('i', 'l')) {
return true;
}
skel(a) == skel(b)
}
pub fn legitimately_identical_ci(a: char, b: char) -> bool {
let la = a.to_lowercase().next().unwrap_or(a);
let lb = b.to_lowercase().next().unwrap_or(b);
la == lb || legitimately_identical(la, lb)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn latin_filter() {
assert_eq!(letter_latin('A' as u32), Some('a'));
assert_eq!(letter_latin('é' as u32), Some('é'));
assert_eq!(letter_latin('Α' as u32), None); assert_eq!(letter_latin('1' as u32), None); }
#[test]
fn homoglyphs_and_real_swaps() {
assert!(legitimately_identical('a', 'a'));
assert!(legitimately_identical('a', '\u{0430}')); assert!(legitimately_identical('b', '\u{03B2}')); assert!(legitimately_identical('\u{00F0}', '\u{0111}')); assert!(legitimately_identical('i', 'l'));
assert!(!legitimately_identical('m', 'd')); assert!(!legitimately_identical('r', 'n'));
}
#[test]
fn case_insensitive_variant() {
assert!(legitimately_identical_ci('M', 'm'));
assert!(!legitimately_identical_ci('M', 'D'));
}
}
const WINDOW_MIN_PAIRS: usize = 3;
const WINDOW_MAX_OCCURRENCES: usize = 2;
pub fn subsetting_window(pairs: &[(char, char, usize)]) -> Option<String> {
if pairs.len() < WINDOW_MIN_PAIRS {
return None;
}
if pairs.iter().any(|&(_, _, n)| n > WINDOW_MAX_OCCURRENCES) {
return None;
}
let mut drawn: Vec<u32> = pairs.iter().map(|&(_, truth, _)| truth as u32).collect();
drawn.sort_unstable();
drawn.dedup();
if drawn.len() != pairs.len() {
return None; }
let span = drawn.last()? - drawn.first()?;
if span as usize != drawn.len() - 1 {
return None; }
let letters: String = drawn.iter().filter_map(|&c| char::from_u32(c)).collect();
Some(format!(
"{} glyph(s) drawing the consecutive letters '{}', each firing at most {}× — the signature of a subset font with a shifted ToUnicode, not a targeted replacement (which has to be systematic to change meaning)",
pairs.len(),
letters,
WINDOW_MAX_OCCURRENCES
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpecimenVerdict {
Corroborated,
Refuted,
Inconclusive,
}
pub fn specimen_verdict(subs: &[(char, char)], specimen_reads: &[(char, char)]) -> SpecimenVerdict {
let (mut pro, mut contra) = (0usize, 0usize);
for &(extracted, claimed_drawn) in subs {
for &(e, read) in specimen_reads.iter().filter(|(e, _)| *e == extracted) {
let _ = e;
if legitimately_identical_ci(read, claimed_drawn) {
pro += 1;
} else if legitimately_identical_ci(read, extracted) {
contra += 1;
}
}
}
match (pro, contra) {
(0, 0) => SpecimenVerdict::Inconclusive,
(0, _) => SpecimenVerdict::Refuted,
(_, 0) => SpecimenVerdict::Corroborated,
_ => SpecimenVerdict::Inconclusive, }
}
#[cfg(test)]
mod subsetting_window_tests {
use super::*;
fn field_report_case() -> Vec<(char, char, usize)> {
vec![('h', 'j', 1), ('ü', 'k', 1), ('þ', 'l', 1)]
}
#[test]
fn riconosce_la_finestra_di_subset_del_field_report() {
let reason = subsetting_window(&field_report_case()).expect("va riconosciuta come artefatto");
assert!(reason.contains("jkl"), "la spiegazione deve mostrare le lettere contigue: {reason}");
}
#[test]
fn una_sostituzione_sistematica_non_e_un_artefatto() {
let attacco = vec![('h', 'j', 40), ('ü', 'k', 12), ('þ', 'l', 9)];
assert!(subsetting_window(&attacco).is_none());
}
#[test]
fn basta_una_coppia_frequente_per_disattivare_la_regola() {
let misto = vec![('h', 'j', 1), ('ü', 'k', 1), ('þ', 'l', 3)];
assert!(subsetting_window(&misto).is_none(), "una sola coppia frequente deve bastare");
}
#[test]
fn lettere_disegnate_non_contigue_non_sono_una_finestra() {
let chirurgico = vec![('l', 'i', 1), ('o', 'e', 1), ('n', 'a', 1)];
assert!(subsetting_window(&chirurgico).is_none());
}
#[test]
fn due_coppie_non_bastano() {
assert!(subsetting_window(&[('h', 'j', 1), ('ü', 'k', 1)]).is_none());
assert!(subsetting_window(&[]).is_none());
}
#[test]
fn coppie_che_disegnano_la_stessa_lettera_non_formano_una_finestra() {
let doppione = vec![('h', 'j', 1), ('ü', 'j', 1), ('þ', 'k', 1)];
assert!(subsetting_window(&doppione).is_none());
}
#[test]
fn la_finestra_regge_a_ordine_sparso_e_a_finestre_piu_lunghe() {
let sparso = vec![('þ', 'l', 1), ('h', 'j', 2), ('ü', 'k', 1)];
assert!(subsetting_window(&sparso).is_some(), "l'ordine delle coppie non deve contare");
let lunga = vec![('a', 'p', 1), ('b', 'q', 1), ('c', 'r', 1), ('d', 's', 1), ('e', 't', 1)];
assert!(subsetting_window(&lunga).is_some());
}
}
#[cfg(test)]
mod specimen_verdict_tests {
use super::*;
const SUBS: [(char, char); 1] = [('h', 'j')];
#[test]
fn lo_specimen_scagiona_quando_il_glifo_disegna_la_lettera_estratta() {
assert_eq!(specimen_verdict(&SUBS, &[('h', 'h')]), SpecimenVerdict::Refuted);
}
#[test]
fn lo_specimen_conferma_quando_il_glifo_disegna_davvero_l_altra_lettera() {
assert_eq!(specimen_verdict(&SUBS, &[('h', 'j')]), SpecimenVerdict::Corroborated);
}
#[test]
fn senza_letture_non_si_decide() {
assert_eq!(specimen_verdict(&SUBS, &[]), SpecimenVerdict::Inconclusive);
assert_eq!(specimen_verdict(&SUBS, &[('z', 'z')]), SpecimenVerdict::Inconclusive);
assert_eq!(specimen_verdict(&SUBS, &[('h', 'w')]), SpecimenVerdict::Inconclusive);
}
#[test]
fn prove_in_conflitto_non_decidono() {
let verdict = specimen_verdict(&SUBS, &[('h', 'h'), ('h', 'j')]);
assert_eq!(verdict, SpecimenVerdict::Inconclusive);
}
#[test]
fn gli_omoglifi_non_creano_falsi_disaccordi() {
assert_eq!(specimen_verdict(&SUBS, &[('h', 'H')]), SpecimenVerdict::Refuted);
}
#[test]
fn piu_sostituzioni_votano_insieme() {
let subs = [('h', 'j'), ('ü', 'k'), ('þ', 'l')];
let reads = [('h', 'h'), ('ü', 'ü'), ('þ', 'þ')];
assert_eq!(specimen_verdict(&subs, &reads), SpecimenVerdict::Refuted);
}
}
#[cfg(test)]
mod ligature_tests {
use super::*;
#[test]
fn una_legatura_non_e_una_sostituzione() {
for lig in ['\u{FB00}', '\u{FB01}', '\u{FB02}', '\u{FB03}', '\u{FB04}'] {
assert!(legitimately_identical('f', lig), "f vs {lig:?} deve essere legittimo");
assert!(legitimately_identical(lig, 'f'), "e simmetrico");
}
assert!(legitimately_identical('s', '\u{FB06}'), "st: la legatura inizia per s");
}
#[test]
fn il_filtro_non_scusa_gli_scambi_veri() {
for (a, b) in [('m', 'd'), ('h', 'j'), ('r', 'l'), ('w', 'l'), ('a', 'e')] {
assert!(!legitimately_identical(a, b), "{a} vs {b} deve restare una sostituzione");
}
assert!(!legitimately_identical('m', '\u{FB01}'), "m non compone 'fi'");
}
}