v3 : septième relecture — collages limités, titre jugé à part, découpage contrôlé
- Mots collés ou coupés : deux au plus, jamais sur une négation (« Si non » → « Sinon », « ne certifier » → « necertifier » sont du fond). - Titre et corps jugés chacun pour soi, puis ensemble : le titre n'absorbe plus le début du corps. - Caractères invisibles refusés aussi au découpage d'un brouillon et à la soumission. - Tabulation et retour chariot ne sont plus des espaces libres ; les lettres latines rares (ǃ, Ʒ…) font partie de la forme. - Le diff renvoyé après une coquille couvre le titre. Boucle : 62 tests Rust, svelte-check, 9 vitest, e2e 12/12. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,15 +80,18 @@ fn skeleton(s: &str) -> String {
|
||||
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
}
|
||||
|
||||
/// A letter of the Latin alphabet, precomposed accents included (after NFC):
|
||||
/// the only letters a typo fix may touch. Any other script may hold look-alikes
|
||||
/// (Cyrillic « а ») or reorder the display (Hebrew, Arabic).
|
||||
/// A letter of the Latin alphabet as French, English and Spanish write it,
|
||||
/// precomposed accents included (after NFC, Latin-1 and Latin Extended-A): the
|
||||
/// only letters a typo fix may touch. Any other letter may be a look-alike
|
||||
/// (Cyrillic « а », « ǃ » for « ! », « Ʒ » for « 3 ») or reorder the display
|
||||
/// (Hebrew, Arabic): it is kept as it is.
|
||||
fn is_latin_letter(c: char) -> bool {
|
||||
c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x24F | 0x1E00..=0x1EFF)
|
||||
c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x17F)
|
||||
}
|
||||
|
||||
/// The only spaces free to change; any other (hair, thin, ogham…) is kept as it is.
|
||||
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n', '\r', '\t'];
|
||||
/// The only spaces free to change; any other (carriage return, tab, hair,
|
||||
/// thin, ogham…) is kept as it is.
|
||||
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n'];
|
||||
|
||||
/// Invisible format characters (zero-width, bidirectional overrides and
|
||||
/// isolates, byte order mark): they can make "12" display as "21" ("Trojan
|
||||
@@ -196,20 +199,28 @@ fn words(s: &str) -> Vec<String> {
|
||||
fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
/// Same letters, words only glued or split: « latransmission » → « la
|
||||
/// transmission ». A boundary moved is not a typo (« les avoir » → « le savoir »).
|
||||
/// Same letters, at most two words glued or split, never a negation:
|
||||
/// « latransmission » → « la transmission ». A boundary moved is not a typo
|
||||
/// (« les avoir » → « le savoir »), nor « Si non » → « Sinon ».
|
||||
fn only_glued_or_split(a: &[String], b: &[String]) -> bool {
|
||||
let (mut i, mut j) = (0, 0);
|
||||
let joined = |x: &String, y: &String| !NEGATIONS.contains(&x.as_str()) && !NEGATIONS.contains(&y.as_str());
|
||||
let (mut i, mut j, mut moves) = (0, 0, 0);
|
||||
while i < a.len() && j < b.len() {
|
||||
if a[i] == b[j] {
|
||||
(i, j) = (i + 1, j + 1);
|
||||
} else if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) {
|
||||
continue;
|
||||
}
|
||||
if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) && joined(&b[j], &b[j + 1]) {
|
||||
(i, j) = (i + 1, j + 2);
|
||||
} else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) {
|
||||
} else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) && joined(&a[i], &a[i + 1]) {
|
||||
(i, j) = (i + 2, j + 1);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
moves += 1;
|
||||
if moves > MAX_TYPO_WORDS {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
i == a.len() && j == b.len()
|
||||
}
|
||||
@@ -361,6 +372,32 @@ mod tests {
|
||||
assert_eq!(classify(old, new), EditKind::Typographic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glued_or_split_words_stay_few_and_never_touch_a_negation() {
|
||||
// Seventh independent review, 2026-10-21: a text in force disfigured at will.
|
||||
for (old, new) in [
|
||||
("à ne certifier que", "à necertifier que"),
|
||||
("à ne certifier que", "àne certifier que"),
|
||||
("Si non, la demande attend", "Sinon, la demande attend"),
|
||||
("la transmission de ce document à chacun", "latransmission dece documentà chacun"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||
}
|
||||
assert_eq!(
|
||||
classify("de latransmission dece document", "de la transmission de ce document"),
|
||||
EditKind::Typographic
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_spaces_and_look_alike_letters_are_kept() {
|
||||
assert_eq!(classify("un texte\tde référence", "un texte de référence"), EditKind::Substantive);
|
||||
assert_eq!(classify("un texte\rde référence", "un texte de référence"), EditKind::Substantive);
|
||||
assert_eq!(classify("toute certification", "toute certiǃfication"), EditKind::Substantive);
|
||||
assert_eq!(classify("toute certification", "toute certifiƷcation"), EditKind::Substantive);
|
||||
assert_eq!(classify("le coeur de l'ŒUVRE, niño", "le Coeur de l'œuvre, Niño"), EditKind::Typographic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accents_case_apostrophes_quotes() {
|
||||
assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier."), EditKind::Typographic);
|
||||
|
||||
Reference in New Issue
Block a user