diff --git a/contributors/people.toml b/contributors/people.toml index 8f358dca8..816997012 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -84,8 +84,10 @@ display_name = "cyril265" [people.webdevtodayjason] display_name = "Jason Brashear" sync_github_name = false -[people.Nael-Nathanael] + +[people.lupant] sync_github_name = true +[people.Nael-Nathanael] [people.Losminek] diff --git a/crates/proof/src/it/grammar.rs b/crates/proof/src/it/grammar.rs index 112b0c2a0..b6b983fc4 100644 --- a/crates/proof/src/it/grammar.rs +++ b/crates/proof/src/it/grammar.rs @@ -9,6 +9,14 @@ //! - **Capital letter at the start of a sentence**, knowing Italian abbreviations (`ecc.`, //! `dott.`, `prof.`, `sig.`), initials, ordinal numbers (`1. posto`) and ellipses. //! - **Doubled punctuation** (`,,`, `..`, `;;`). +//! - **`po'`**: `un pò` and `un po` → `un po'` (a truncation of `poco`: apostrophe, no accent). +//! - **`qual è`**: `qual'è`, `qual'era` → `qual è`, `qual era` (a truncation, never elided). +//! - **`È`**: `E'` written for the verb → `È`. +//! - **`un` / `un'`**: the apostrophe only before a feminine word. `un'altro`, `un'anno` → +//! `un altro`, `un anno` (a masculine word in `-o`; `auto` and `eco` are feminine), and +//! `un altra`, `un amica` → `un'altra`, `un'amica` (a word in `-a`, except the masculine and +//! common-gender ones: `-ista`, `-ita`, `-ota`, `-cida`, `-ma`, `-eta`, `atleta`, `acrobata`…). +//! The same for `nessun`, `ciascun`, `qualcun` and `alcun`. //! //! No comma rule and no English rule (such as `a`/`an`) applies to Italian text. @@ -17,9 +25,22 @@ use crate::{Issue, IssueKind, ProofLang, words}; /// Words Italian repeats on purpose: emphatic doubling, interjections, onomatopoeia. const ALLOWED_REPEATS: &[&str] = &[ "sì", "no", "piano", "bene", "male", "ecco", "oh", "ah", "eh", "mh", "bah", "ehi", "urrà", "evviva", "ciao", "addio", "adagio", "forte", "lento", - "chiaro", + "chiaro", "pian", "poco", "quasi", "presto", "subito", "zitto", "via", "così", "tanto", "molto", "pure", "dai", "su", "avanti", "basta", "aiuto", ]; +/// Indefinite articles (and the like) that elide to `un'` before a feminine word only. +const UN_FORMS: &[&str] = &["un", "nessun", "ciascun", "qualcun", "alcun"]; + +/// Feminine words in `-o` that start with a vowel (`un'auto`, `un'eco`). +const FEMININE_IN_O: &[&str] = &["auto", "autoradio", "eco", "ecografo"]; + +/// Endings of masculine or common-gender words in `-a` (`un artista`, `un idiota`, `un enigma`). +const MASCULINE_A_ENDINGS: &[&str] = &["ista", "ita", "ota", "cida", "ma", "eta", "nauta", "iatra", "arca"]; + +/// Words in `-a` after `un` that aren't feminine nouns: masculine or common-gender ones that fit +/// no ending above, and verbs and pronouns (`un ha`, `un una`). +const NOT_FEMININE_IN_A: &[&str] = &["acrobata", "autodidatta", "alfa", "omega", "ha", "una"]; + /// Abbreviations written with a final period after which a sentence goes on in lower case /// (lower case, without the period). const ABBREVIATIONS: &[&str] = &[ @@ -210,6 +231,100 @@ fn doubled_punctuation(text: &str, v: &mut Vec) { } } +/// Is `c` an apostrophe (straight or typographic)? +fn is_apostrophe(c: char) -> bool { + matches!(c, '\'' | '’') +} + +/// `pò` (never accented) and `po` without its apostrophe → `po'`. `Po` (the river) is left alone. +fn po(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + for (a, b) in ws { + let Some(w) = text.get(*a..*b) else { continue }; + let apostrophe = text.get(*b..).and_then(|r| r.chars().next()).is_some_and(is_apostrophe); + let fix = match w { + "pò" => "po'", + "Pò" => "Po'", + "po" if !apostrophe => "po'", + _ => continue, + }; + v.push(issue(*a, *b, "Si scrive «po'», con l'apostrofo e senza accento".into(), vec![fix.into()])); + } +} + +/// `qual'è` → `qual è` (any case): the fix when `word` is `qual` + apostrophe + `è`/`era`. +pub(crate) fn qual_elided(word: &str) -> Option { + let head_end = word.char_indices().take(4).last().map(|(i, c)| i + c.len_utf8())?; + let (head, rest) = (word.get(..head_end)?, word.get(head_end..)?); + let tail = rest.strip_prefix(is_apostrophe)?; + let tail_ok = matches!(tail.to_lowercase().as_str(), "è" | "era"); + (head.eq_ignore_ascii_case("qual") && tail_ok).then(|| format!("{head} {tail}")) +} + +fn qual_e(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + for (a, b) in ws { + if let Some(fix) = text.get(*a..*b).and_then(qual_elided) { + v.push(issue(*a, *b, "«Qual è» si scrive senza apostrofo".into(), vec![fix])); + } + } +} + +/// `E'` for the verb → `È`. A quoted letter (`la lettera 'E'`) is left alone. +fn e_apostrophe(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + for (a, b) in ws { + if text.get(*a..*b) != Some("E") { + continue; + } + let mut after = text.get(*b..).unwrap_or("").chars(); + let Some(ap) = after.next().filter(|&c| is_apostrophe(c)) else { continue }; + let ends = after.next().is_none_or(|c| c.is_whitespace()); + let quoted = text.get(..*a).and_then(|t| t.chars().next_back()).is_some_and(|c| is_apostrophe(c) || c == '‘'); + if ends && !quoted { + v.push(issue(*a, *b + ap.len_utf8(), "«È» si scrive con l'accento, non con l'apostrofo".into(), vec!["È".into()])); + } + } +} + +/// Does `w` (lower case) start with a vowel or `h`, as the words `un'` elides before? +fn starts_with_vowel(w: &str) -> bool { + w.chars().next().is_some_and(|c| "aeiouhàèéìòóù".contains(c)) +} + +/// Is `w` (lower case) a feminine word in `-a` (`altra`, `amica`, `ora`), as far as the ending +/// tells? +fn feminine_in_a(w: &str) -> bool { + w.chars().count() >= 2 && w.ends_with('a') && !MASCULINE_A_ENDINGS.iter().any(|e| w.ends_with(e)) && !NOT_FEMININE_IN_A.contains(&w) +} + +/// `un'` before a masculine word (`un'altro` → `un altro`), and `un` without the apostrophe +/// before a feminine one (`un altra` → `un'altra`). Only lower-case words: a name's gender isn't +/// in its ending. +fn un_apostrophe(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + for (a, b) in ws { + let Some(w) = text.get(*a..*b) else { continue }; + let Some((head, tail)) = w.split_once(is_apostrophe) else { continue }; + if UN_FORMS.contains(&head.to_lowercase().as_str()) + && tail.chars().all(|c| c.is_alphabetic() && c.is_lowercase()) + && starts_with_vowel(tail) + && tail.ends_with('o') + && !FEMININE_IN_O.contains(&tail) + { + v.push(issue(*a, *b, format!("Davanti a un nome maschile «{head}» non vuole l'apostrofo"), vec![format!("{head} {tail}")])); + } + } + for pair in ws.windows(2) { + let [(a0, b0), (a1, b1)] = pair else { continue }; + let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue }; + if gap == " " + && UN_FORMS.contains(&w0.to_lowercase().as_str()) + && w1.chars().all(|c| c.is_alphabetic() && c.is_lowercase()) + && starts_with_vowel(w1) + && feminine_in_a(w1) + { + v.push(issue(*a0, *b1, format!("Davanti a un nome femminile si scrive «{w0}'», con l'apostrofo"), vec![format!("{w0}'{w1}")])); + } + } +} + /// Grammar issues in a paragraph of Italian text, in order. pub fn check(text: &str) -> Vec { let ws: Vec<(usize, usize)> = words(text); @@ -218,6 +333,10 @@ pub fn check(text: &str) -> Vec { spaces(text, &mut v); sentence_capitals(text, &ws, &mut v); doubled_punctuation(text, &mut v); + po(text, &ws, &mut v); + qual_e(text, &ws, &mut v); + e_apostrophe(text, &ws, &mut v); + un_apostrophe(text, &ws, &mut v); v.sort_by_key(|i| (i.start, i.end)); v } @@ -250,6 +369,97 @@ mod tests { assert!(check("Sì sì, bene bene :)").is_empty()); } + /// The fixes the rules offer for `t`, in order. + fn fixes(t: &str) -> Vec<(String, String)> { + check(t).into_iter().map(|i| (t.get(i.start..i.end).unwrap_or("").to_string(), i.suggestions.join("|"))).collect() + } + + fn pair(a: &str, b: &str) -> (String, String) { + (a.to_string(), b.to_string()) + } + + #[test] + fn po_takes_an_apostrophe() { + assert_eq!(fixes("Dammi un pò di pane"), [pair("pò", "po'")]); + assert_eq!(fixes("Dammi un po di pane"), [pair("po", "po'")]); + assert_eq!(fixes("Pò di più"), [pair("Pò", "Po'")]); + assert!(fixes("Dammi un po' di pane, un po’ d'acqua").is_empty()); + assert!(fixes("Il Po scorre a Torino").is_empty(), "the river"); + } + + #[test] + fn qual_e_has_no_apostrophe() { + assert_eq!(fixes("Dimmi qual'è il problema"), [pair("qual'è", "qual è")]); + assert_eq!(fixes("Qual’era la strada?"), [pair("Qual’era", "Qual era")]); + assert!(fixes("Qual è il problema? Quale era?").is_empty()); + assert!(fixes("Quale'è").is_empty(), "not the truncation"); + assert_eq!(qual_elided("qual'è").as_deref(), Some("qual è")); + assert_eq!(qual_elided("qua"), None); + assert_eq!(qual_elided(""), None); + } + + #[test] + fn e_apostrophe_becomes_e_grave() { + assert_eq!(fixes("E' vero"), [pair("E'", "È")]); + assert_eq!(fixes("Lo so. E’ tardi"), [pair("E’", "È")]); + assert!(fixes("È vero. E poi?").is_empty()); + assert!(fixes("La lettera 'E' è una vocale").is_empty(), "a quoted letter"); + } + + #[test] + fn un_apostrophe_follows_the_gender() { + assert_eq!(fixes("Ho visto un'altro amico"), [pair("un'altro", "un altro")]); + assert_eq!(fixes("È passato un’anno"), [pair("un’anno", "un anno")]); + assert_eq!(fixes("Prendo un altra strada"), [pair("un altra", "un'altra")]); + assert_eq!(fixes("Ho un amica a Roma"), [pair("un amica", "un'amica")]); + assert_eq!(fixes("Non c'è nessun altra via"), [pair("nessun altra", "nessun'altra")]); + for ok in [ + "Un altro amico", + "Un'altra amica", + "Un'auto nuova", + "Un'eco lontana", + "Un artista", + "Un atleta", + "Un idiota", + "Un ipocrita", + "Un enigma", + "Un omicida", + "Un astronauta", + "Un acrobata", + "Un'ora", + "Un'idea", + "Un uomo", + "Un'Alfa", + "Un Andrea", + "Un ha", + ] { + assert!(fixes(ok).is_empty(), "{ok}: {:?}", fixes(ok)); + } + } + + #[test] + fn more_emphatic_doubles_are_allowed() { + for ok in ["Pian piano", "Poco poco", "Zitto zitto", "Via via", "Così così", "Dai dai"] { + assert!(fixes(ok).is_empty(), "{ok}: {:?}", fixes(ok)); + } + } + + proptest::proptest! { + #[test] + fn never_panics(s in "\\PC{0,80}") { + for i in check(&s) { + proptest::prop_assert!(i.start <= i.end && i.end <= s.len()); + } + } + + #[test] + fn never_panics_on_italian_like_text(s in "[a-zàèéìòóùA-ZÈ ,.;:!?'’‘\"«»…\\-]{0,60}") { + for i in check(&s) { + proptest::prop_assert!(i.start <= i.end && i.end <= s.len() && s.is_char_boundary(i.start) && s.is_char_boundary(i.end)); + } + } + } + fn test_dict_install() { crate::it::test_dict::install(); } diff --git a/crates/proof/src/it/mod.rs b/crates/proof/src/it/mod.rs index 7e1635599..c8e33f14c 100644 --- a/crates/proof/src/it/mod.rs +++ b/crates/proof/src/it/mod.rs @@ -4,12 +4,14 @@ //! [`crate::hunspell`]: `it_IT.aff`/`it_IT.dic` from the OS, a package manager or //! LibreOffice; it isn't bundled — the upstream file is GPL-licensed). Without it Italian //! text gets no spelling marks. Elided forms check the word after the apostrophe -//! (`l'amico`, `un'idea`, `c'è`); hyphenated compounds are checked part by part; the user -//! dictionary applies as in English. +//! (`l'amico`, `un'idea`, `c'è`, `dov'è`, `senz'altro`); truncations keep their apostrophe +//! (`po'`, `mo'`); hyphenated compounds are checked part by part; the user dictionary applies +//! as in English. Forms a grammar rule explains better (`pò`, `qual'è`) are left to it. //! - **Suggestions** tuned for Italian ([`suggest`]): missing accents first (`caffe` → `caffè`, //! `perche` → `perché`), then the dictionary's `REP` confusions, a missing space, one-letter //! typos, and a dictionary scan for anything further off. -//! - **Grammar** ([`grammar`]): cheap, reliable rules with Italian messages. +//! - **Grammar** ([`grammar`]): cheap, reliable rules with Italian messages, which work with +//! no dictionary installed. pub mod grammar; mod suggest; @@ -84,12 +86,23 @@ fn is_latin_letter(c: char) -> bool { } /// Proclitics (and verb clippings) that elide before a vowel or `h`: the word after the -/// apostrophe is what gets checked (`l'amico`, `un'idea`, `c'è`, `m'hai`). +/// apostrophe is what gets checked (`l'amico`, `un'idea`, `c'è`, `m'hai`, `dov'è`, `anch'io`). const PROCLITICS: &[&str] = &[ - "l", "un", "una", "uno", "all", "dall", "dell", "nell", "sull", "coll", "pell", "c", "m", "t", "s", "v", "d", "j", "quest", "quell", "bell", - "sant", "grand", "poc", + "l", "un", "una", "uno", "all", "dall", "dell", "nell", "sull", "coll", "pell", "c", "m", "t", "s", "v", "d", "j", "n", "ch", "gl", "quest", + "quell", "bell", "buon", "sant", "grand", "poc", "nessun", "ciascun", "qualcun", "alcun", "dov", "cos", "com", "quand", "anch", "senz", "tutt", + "nient", "mezz", "sott", "tant", "quant", "dev", ]; +/// Truncated words written with an apostrophe (`un po'`, `a mo' di`, `be'`): [`crate::words`] +/// trims the apostrophe, so spelling sees the bare word; [`grammar`] checks the apostrophe. +const TRUNCATIONS: &[&str] = &["po", "mo", "be"]; + +/// Misspellings a grammar rule reports with a clearer message and fix (`pò` → `po'`, `qual'è` +/// → `qual è`), so spelling leaves them alone rather than marking the word twice. +fn left_to_grammar(lower: &str) -> bool { + lower == "pò" || grammar::qual_elided(lower).is_some() +} + /// Is `word` spelled correctly in Italian? Numbers, single letters, short words in capitals /// (acronyms: `RAI`, `TG`), words with digits and words in other scripts are accepted, and so /// is every word when no Italian dictionary is installed. @@ -114,6 +127,10 @@ pub fn is_correct_with(d: &AffixDict, word: &str) -> bool { if !w.chars().any(is_latin_letter) { return true; // other scripts: no dictionary } + let lower = w.to_lowercase(); + if TRUNCATIONS.contains(&lower.as_str()) || left_to_grammar(&lower) { + return true; + } if w.contains('-') { return w.split('-').all(|p| p.chars().count() <= 1 || known(d, p)); } @@ -178,14 +195,32 @@ mod tests { } } + #[test] + fn elisions_and_truncations_are_correct() { + test_dict::install(); + for w in ["dov'è", "Com'è", "cos'è", "anch'io", "senz'acqua", "tutt'uno", "quand'è", "nessun'amica", "n'ha", "po'", "po", "mo'"] { + assert!(is_correct(w), "{w}"); + } + } + #[test] fn misspellings_are_flagged() { test_dict::install(); - for w in ["caffé", "perche", "lbiro", "parlre", "amicaa", "citta", "quelo"] { + for w in ["caffé", "perche", "lbiro", "parlre", "amicaa", "citta", "quelo", "dov'lbiro", "xyz'casa"] { assert!(!is_correct(w), "{w}"); } } + #[test] + fn grammar_owned_forms_are_not_marked_twice() { + test_dict::install(); + // `pò` and `qual'è` are reported by the grammar rules, with the fix; spelling stays quiet. + for w in ["pò", "Pò", "qual'è", "Qual’era"] { + assert!(is_correct(w), "{w}"); + assert!(!grammar::check(w).is_empty(), "{w}: grammar reports it"); + } + } + #[test] fn user_dictionary_applies() { test_dict::install(); diff --git a/crates/proof/src/it/test_dict.rs b/crates/proof/src/it/test_dict.rs index 16fd8f649..5d594d2fd 100644 --- a/crates/proof/src/it/test_dict.rs +++ b/crates/proof/src/it/test_dict.rs @@ -30,7 +30,7 @@ PFX R 0 ri . "; /// Stems (the count line first, as in Hunspell files). -pub const DIC: &str = "96 +pub const DIC: &str = "97 città caffè perché @@ -127,6 +127,7 @@ a per me quello +io "; /// Make [`AFF`]/[`DIC`] the Italian dictionary (every test that needs one calls this; they all diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 17a96bb9b..369025527 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -1298,14 +1298,19 @@ mod tests { let _ = suggest_in(&s, 4, ProofLang::Pt); } + #[test] + fn italian_suggestions_never_panic(s in "[a-zàèéìòóùA-ZÀÈÉÌÒÓÙ'’ -]{0,14}") { + let _ = suggest_in(&s, 4, ProofLang::It); + } + #[test] fn turkish_suggestions_never_panic(s in "[a-zçğıöşüA-ZÇĞİÖŞÜ'’ -]{0,14}") { let _ = suggest_in(&s, 4, ProofLang::Tr); } #[test] - fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..6), 0..6)) { - let langs = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::Tr, ProofLang::Ru, ProofLang::Other]; + fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..7), 0..6)) { + let langs = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Tr, ProofLang::Ru, ProofLang::Other]; let spans: Vec<(Range, ProofLang)> = cuts.iter().map(|&(a, b, l)| (a.min(b)..a.max(b), langs[l as usize % langs.len()])).collect(); for i in check_text(&s, &spans) { proptest::prop_assert!(i.start <= i.end && i.end <= s.len()); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9909ea42d..2e4cbcdf8 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -102,7 +102,7 @@ other than envelopes (Letter, Legal, A4, B5 (JIS)) stay as they are (#1128). | Russian (`ru`) | A Russian Hunspell dictionary installed on the system (`ru_RU` from the OS, a package manager, LibreOffice's `dict-ru` or the `WORDCRAFT_DICTIONARIES` folder; UTF-8, KOI8-R or Windows-1251; not bundled, so no Russian spelling marks without it), read by the same affix engine; `ё` optional (`елка`/`ёлка`, `еще`/`ещё`); hyphenated words whole, then by parts (`кто-то`, `по-русски`, `кое-где`, `северо-западный`, `ИИ-консультант`); solid compounds (`высокоэластичный`), `не` with adjectives, participles and verbal nouns (not verbs), productive prefixes (`ультра-`, `эко-`) and prefixed verbs; abbreviations with endings (`ГИПа`) and camel case left alone; our supplement of ~200 modern words with their declensions (`assets/spelling/ru-extra.txt`: `маркетплейс`, `промокод`, `интеграция`, `колеровать`) | `ё`/`е` first, then slips within letter groups (unstressed vowels, voiced and voiceless pairs, `ь`/`ъ`), the common confusions (`жы`, `чя`, `тся`/`ться`, doubled consonants, unpronounced consonants: `здраствуй` → `здравствуй`), a missing space (`незнаю` → `не знаю`; not a preposition before a verb), one typo, then a stem scan | Russian messages: repeated word, space before punctuation, double space, sentence capital (knows `т. е.`, `г.`, `руб.`, `ул.`, initials, numbers, dialogue dashes), possibly missing comma before `что`/`чтобы`/`если`/`хотя`/`поскольку`/`который` and before `потому что` (not after conjunctions and particles such as `и`, `только`, `даже`, `так`, `для того`; not after a preposition; not in `хотя бы`, `что-то`), doubled punctuation | The rules of Russian word division as code (no pattern file): a vowel on each side, two letters at least, `ь`/`ъ`/`й` kept with the letter before, a break after `ъ`, a consonant cluster divided after its first consonant | | Polish (`pl`) | The Hunspell `pl_PL` dictionary installed on the system (OS package, Homebrew, LibreOffice, the folder set in File › Options › Proofing › Dictionaries folder, or `WORDCRAFT_DICTIONARIES`; not bundled, so no Polish spelling marks without it; the same pane shows which file each language found), read at runtime by a Hunspell-style affix engine (`crates/proof/src/affix.rs`); names capitalized, compounds part by part, `Kennedy'ego` | missing diacritics first (`zolw` → `żółw`), the dictionary's confusion table (`żeka` → `rzeka`, `chuśtawka` → `huśtawka`), missing space (`napewno` → `na pewno`), one typo, then a stem scan within two typos | Polish messages: repeated word, space before punctuation, double space, sentence capital (knows `np.`, `r.`, initials, ordinals, dialogue dashes), missing comma before `że`/`który`/`ponieważ`/`gdyż`/`aby`/`żeby`/`jeśli`/`jeżeli`/`gdyby`/`lecz` (not after `mimo`, `chyba`, `tak`, prepositions…), `we`/`ze` (`we wtorek`, `ze szkoły`), doubled punctuation | TeX Polish patterns (Kołodziejska, Jackowski, Ryćko; MIT option) with their 20 exception words; a Polish syllable heuristic for letters outside the alphabet | | Portuguese (`pt`, any region) | The Hunspell `pt_BR` dictionary installed on the system — VERO and its derivatives (same places as Polish; not bundled, LGPL/MPL), read at runtime by the same affix engine, which reads its `FLAG UTF-8`, 106 flags, continuation classes and `FORBIDDENWORD` spellings; names capitalized, compounds part by part (`guarda-roupa`, `ex-marido`) | missing accents first (`coraçao` → `coração`, `saude` → `saúde`), the dictionary's confusion table (`seje` → `seja`, `excessão` → `exceção`), missing space (`nãoé` → `não é`), one typo, then an accent-folding stem scan | Portuguese messages: repeated word (the emphatic doubles are allowed), space before punctuation, double space, sentence capital (knows `etc.`, `pág.`, `Dr.`, initials, ordinals, dialogue dashes), doubled punctuation; no comma rule — `que` opens a clause only sometimes | TeX Portuguese patterns (Rezende, Almeida, Araujo, Benevides; BSD 3-clause) with their 2 exception words | -| Italian (`it`, any region) | The Hunspell `it_IT` dictionary installed on the system (same places as Polish and Portuguese; not bundled — the upstream file is GPL-licensed), read at runtime by the same affix engine; elided forms check the word after the apostrophe (`l'amico`, `un'idea`, `c'è`), hyphenated compounds part by part | missing accents first (`caffe` → `caffè`, `perche` → `perché`), the dictionary's confusion table, missing space (`perme` → `per me`), one typo, then an accent-folding stem scan | Italian messages: repeated word (emphatic doubles like `piano piano` are allowed), space before punctuation, double space, sentence capital (knows `ecc.`, `dott.`, `prof.`, initials, ordinals), doubled punctuation; no comma rule | TeX Italian patterns (Beccari; MIT option), no exception words | +| Italian (`it`, any region) | The Hunspell `it_IT` dictionary installed on the system (same places as Polish and Portuguese; not bundled — the upstream file is GPL-licensed), read at runtime by the same affix engine; elided forms check the word after the apostrophe (`l'amico`, `un'idea`, `c'è`, `dov'è`, `senz'altro`), truncations keep their apostrophe (`po'`, `mo'`), hyphenated compounds part by part | missing accents first (`caffe` → `caffè`, `perche` → `perché`), the dictionary's confusion table, missing space (`perme` → `per me`), one typo, then an accent-folding stem scan | Italian messages: repeated word (emphatic doubles like `piano piano` are allowed), space before punctuation, double space, sentence capital (knows `ecc.`, `dott.`, `prof.`, initials, ordinals), doubled punctuation, `un pò` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'altro` → `un altro` and `un altra` → `un'altra` (gender from the ending, masculine `-ista`/`-ma`/`-cida`… left alone); these work without a dictionary; no comma rule | TeX Italian patterns (Beccari; MIT option), no exception words | | Turkish (`tr`, any region) | The Hunspell `tr_TR` dictionary installed on the system (same places as Polish; not bundled), read at runtime; proper nouns capitalized, apostrophe suffixes (`Ankara'da`, `WordCraft'ta`) and hyphenated compounds | missing diacritics / ASCII restoration first (`turkce` → `Türkçe`, `isik` → `ışık`), common confusion table (`yalnış` → `yanlış`, `herşey` → `her şey`), detached suffixes (`bende` → `ben de`, `geldinmi` → `geldi mi`), one typo | Turkish messages: repeated word (idiomatic reduplications allowed: `yavaş yavaş`), space before punctuation, double space, sentence capital (knows `vb.`, `vs.`, `prof.`, `dr.`), no comma before/after conjunctions (`ve`, `veya`, `de`), doubled punctuation | Turkish syllable rules (TDK rules); no external pattern file or word list needed | | Any other | — (no marks) | — | — | the English patterns, as before | @@ -121,7 +121,7 @@ Italian (`it`, #594) follows `it`, `it-IT` and `it-CH` system locales. Its catal the others plus 51 strings that are wrapped in `tl!` but were in no catalog yet (File › Info properties, the File › Home greeting, list levels, Equation tab gallery and tooltips). Ribbon tabs, groups and command names use the names Italian Word users know (feature names, as `AGENTS.md` -allows); all other text is written from the meaning of the English. No Italian proofing yet. +allows); all other text is written from the meaning of the English. Italian proofing: see the table above. ## Sorting and numbers in the text's language Sorts follow the alphabet of the text's language (`w:lang`, else the document default): @@ -184,6 +184,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Italian grammar rules that work without a dictionary: `un pò`/`un po` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'` only before feminine words (`un'altro` → `un altro`, `un altra` → `un'altra`, also `nessun`/`ciascun`/`qualcun`/`alcun`); spelling accepts more elisions (`dov'è`, `anch'io`, `senz'altro`, `nessun'altra`) and truncations (`po'`, `mo'`) and leaves `pò`/`qual'è` to grammar so they aren't marked twice; more emphatic doubles allowed | | 2026-10-12 | minor | File › Options › Proofing › Dictionaries folder (#1063): a saved folder of Hunspell dictionaries searched first, so an app started from Finder or the Dock finds them without `WORDCRAFT_DICTIONARIES`; the pane lists the dictionary file each language found — Polish, Portuguese, Italian, Croatian, Turkish (`file.options` `dictionariesFolder`) | | 2026-10-12 | minor | Czech interface catalog completed: 386 entries added (2,447 total), including built-in style names, numbered families, equation gallery, watermarks, paper sizes and preview tiles; coverage test requires every Russian and Spanish key and retains Chinese coverage | | 2026-10-12 | minor | Italian proofing: it-IT spelling from the installed Hunspell dictionary (system `it_IT`, never bundled — upstream is GPL), accent-first suggestions, conservative grammar with Italian messages, TeX Italian hyphenation patterns (MIT option); Review › Language already offered Italian; an `it` interface writes it-IT documents |