From ec1f25e6177a1ff7406ec0db7789de8016abcb1f Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:52:53 +0300 Subject: [PATCH] Review: spelling with Hunspell dictionaries, suggestions, Change and Ignore All --- crates/engine/src/cmd/review.rs | 220 +++++++++-- crates/engine/src/lib.rs | 1 + crates/engine/src/spell.rs | 566 +++++++++++++++++++++++++++ crates/engine/src/tests.rs | 36 ++ crates/ui-egui/src/dialogs.rs | 76 +++- crates/ui-egui/src/spelling_tests.rs | 46 +++ docs/gaps.md | 11 +- 7 files changed, 910 insertions(+), 46 deletions(-) create mode 100644 crates/engine/src/spell.rs create mode 100644 crates/ui-egui/src/spelling_tests.rs diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 8e3a532c..822d6e92 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -16,7 +16,17 @@ pub fn specs() -> Vec { cmd!("comment.resolve", "Resolve Thread", ["Comments"], None, "{index, resolved?: bool}", has_slide, resolve), cmd!(query "comment.list", "Comments", [], None, "{slide?} → comments", has_slide, list), cmd!(query "review.accessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → [{slide, shape, issue}]", has_doc, accessibility), - cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{} → [{slide, shape, word}] (words not in the built-in list)", has_doc, spelling), + cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{lang?, ignore?:[word]} → [{slide, shape, word, lang, suggestions}] (Hunspell dictionaries installed on the system, else a built-in list of common misspellings)", has_doc, spelling), + cmd!( + "review.spellingChange", + "Change Spelling", + ["Review", "Proofing"], + None, + "{shape, word, to, all?} → {changed} (replace the whole word in that shape's text, or in every shape with all)", + has_doc, + spelling_change + ), + cmd!(query "review.dictionary", "Spelling Dictionary", ["Review", "Proofing"], None, "{lang?} → {lang, available, found, folders} (whether review.spelling has a dictionary, and which file)", always, dictionary_info), cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)), cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| { let i = s.doc()?.selection.slide; @@ -168,45 +178,85 @@ fn accessibility(s: &mut Session, _p: &Value) -> Result { Ok(Value::Array(out)) } -fn spelling(s: &mut Session, _p: &Value) -> Result { - // A small built-in list of frequent misspellings (full dictionaries come later). - const COMMON: &[&str] = &[ - "teh", - "recieve", - "seperate", - "occured", - "untill", - "wich", - "becuase", - "definately", - "thier", - "alot", - "accomodate", - "acheive", - "adress", - "begining", - "beleive", - "calender", - "commited", - "enviroment", - "goverment", - "independant", - "neccessary", - "occassion", - "publically", - "reccomend", - "tommorow", - "wierd", - ]; +/// Frequent misspellings, used when no dictionary for the language is installed. +const COMMON: &[&str] = &[ + "teh", + "recieve", + "seperate", + "occured", + "untill", + "wich", + "becuase", + "definately", + "thier", + "alot", + "accomodate", + "acheive", + "adress", + "begining", + "beleive", + "calender", + "commited", + "enviroment", + "goverment", + "independant", + "neccessary", + "occassion", + "publically", + "reccomend", + "tommorow", + "wierd", +]; + +/// The language a check uses: the `lang` parameter, else the first run that says, else US English. +fn check_lang(doc: &deckcraft_model::Presentation, p: &Value) -> String { + if let Some(l) = str_param(p, "lang") { + return l.to_string(); + } + let mut found = None; + for sl in &doc.slides { + deckcraft_model::walk(&sl.shapes, &mut |sh, _| { + if found.is_none() + && let Some(t) = &sh.text + { + found = t.paragraphs.iter().flat_map(|p| &p.runs).find_map(|r| r.props.lang.clone()); + } + }); + } + found.unwrap_or_else(|| "en-US".into()) +} + +/// The words of `text`: letters, digits, apostrophes inside a word and the dictionary's extras. +fn words<'a>(text: &'a str, extra: &[char]) -> Vec<&'a str> { + text.split(|c: char| !(c.is_alphanumeric() || c == '\'' || c == '’' || extra.contains(&c))) + .map(|w| w.trim_matches(|c| c == '\'' || c == '’')) + .filter(|w| !w.is_empty() && w.chars().any(char::is_alphabetic) && !w.chars().any(|c| c.is_ascii_digit())) + .collect() +} + +fn spelling(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; + let lang = check_lang(&st.doc, p); + let ignore: Vec = + p.get("ignore").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_lowercase).collect()).unwrap_or_default(); + let dict = crate::spell::dictionary(&lang); + let extra: Vec = dict.as_ref().map(|d| d.word_chars().to_vec()).unwrap_or_default(); let mut out = vec![]; for (i, sl) in st.doc.slides.iter().enumerate() { deckcraft_model::walk(&sl.shapes, &mut |sh, _| { - if let Some(t) = &sh.text { - for w in t.text().split(|c: char| !c.is_alphanumeric() && c != '\'') { - if COMMON.contains(&w.to_lowercase().as_str()) { - out.push(json!({"slide": i, "shape": sh.id, "word": w})); - } + let Some(t) = &sh.text else { return }; + let text = t.text(); + for w in words(&text, &extra) { + if ignore.contains(&w.to_lowercase()) { + continue; + } + let bad = match &dict { + Some(d) => !d.check(w), + None => COMMON.contains(&w.to_lowercase().as_str()), + }; + if bad { + let sug = dict.as_ref().map(|d| d.suggest(w, 5)).unwrap_or_default(); + out.push(json!({"slide": i, "shape": sh.id, "word": w, "lang": lang, "suggestions": sug})); } } }); @@ -214,6 +264,104 @@ fn spelling(s: &mut Session, _p: &Value) -> Result { Ok(Value::Array(out)) } +/// Replace whole-word `from` with `to` in `text`; how many were replaced. +fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize { + let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’'; + let mut n = 0; + let mut at = 0; + while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) { + let end = i + from.len(); + let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word); + let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word); + if before || after { + at = end; + continue; + } + text.replace_range(i..end, to); + n += 1; + at = i + to.len(); + if !all { + break; + } + } + n +} + +fn spelling_change(s: &mut Session, p: &Value) -> Result { + let word = str_param(p, "word").filter(|w| !w.is_empty()).ok_or_else(|| bad("review.spellingChange", "missing `word`"))?.to_string(); + let to = str_param(p, "to").ok_or_else(|| bad("review.spellingChange", "missing `to`"))?.to_string(); + let all = bool_or(p, "all", false); + let shape = p.get("shape").and_then(Value::as_u64); + if shape.is_none() && !all { + return Err(bad("review.spellingChange", "give `shape`, or `all: true`")); + } + // Nothing to change makes no undo step. + let mut any = false; + let st = s.doc()?; + for sl in &st.doc.slides { + deckcraft_model::walk(&sl.shapes, &mut |sh, _| { + if shape.is_none_or(|id| serde_json::to_value(sh.id).ok().and_then(|v| v.as_u64()) == Some(id)) + && let Some(t) = &sh.text + { + any |= t.paragraphs.iter().flat_map(|p| &p.runs).any(|r| replace_word(&mut r.text.clone(), &word, &to, false) > 0); + } + }); + } + if !any { + return Ok(json!({"changed": 0})); + } + let changed = s.edit(|doc, _| { + let mut n = 0; + let id_of = |sh: &deckcraft_model::Shape| serde_json::to_value(sh.id).ok().and_then(|v| v.as_u64()); + for sl in &mut doc.slides { + let mut hit = false; + deckcraft_model::walk(&sl.shapes, &mut |sh, _| { + hit |= shape.is_none_or(|id| id_of(sh) == Some(id)) && sh.text.as_ref().is_some_and(|t| t.text().contains(word.as_str())); + }); + if !hit || (!all && n > 0) { + continue; + } + walk_text_mut(&mut Arc::make_mut(sl).shapes, 0, &mut |sh| { + if shape.is_some_and(|id| id_of(sh) != Some(id)) || (!all && n > 0) { + return; + } + let Some(t) = sh.text.as_mut() else { return }; + for r in t.paragraphs.iter_mut().flat_map(|p| p.runs.iter_mut()) { + if !all && n > 0 { + break; + } + n += replace_word(&mut r.text, &word, &to, all); + } + }); + } + Ok(n) + })?; + Ok(json!({"changed": changed})) +} + +fn walk_text_mut(shapes: &mut [deckcraft_model::Shape], depth: usize, f: &mut dyn FnMut(&mut deckcraft_model::Shape)) { + if depth > 64 { + return; + } + for sh in shapes.iter_mut() { + f(sh); + if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind { + walk_text_mut(children, depth + 1, f); + } + } +} + +fn dictionary_info(s: &mut Session, p: &Value) -> Result { + let lang = match s.doc() { + Ok(st) => check_lang(&st.doc, p), + Err(_) => str_param(p, "lang").unwrap_or("en-US").to_string(), + }; + let dirs = crate::spell::search_dirs(); + let found = crate::spell::find_in(&dirs, &lang).map(|(a, _)| a.with_extension("").display().to_string()); + let available = crate::spell::dictionary(&lang).is_some(); + Ok(json!({"lang": lang, "available": available, "found": found, "folders": dirs.iter().map(|d| d.display().to_string()).collect::>()})) +} + fn start(s: &mut Session, from: usize) -> Result { s.ui_requests.push(UiRequest::StartShow { from }); Ok(json!({"from": from})) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..916d55ec 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,7 @@ pub mod keys; pub mod links; pub mod recovery; pub mod sample; +pub mod spell; pub mod tools; use std::sync::Arc; diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs new file mode 100644 index 00000000..4c089812 --- /dev/null +++ b/crates/engine/src/spell.rs @@ -0,0 +1,566 @@ +//! Spell checking with Hunspell-format dictionaries (`xx_YY.aff` + `xx_YY.dic`). +//! +//! A subset of Hunspell: `SET` (UTF-8 and ISO-8859-1), `FLAG` (default, `long`, `num`, `UTF-8`), +//! `PFX`/`SFX` with strip, conditions and cross products, continuation flags on affixes (one +//! level: a suffix that allows a further suffix), `NEEDAFFIX`, `FORBIDDENWORD`, `NOSUGGEST`, +//! `KEEPCASE`, `TRY`, `REP` and `WORDCHARS`. Compounding, `ICONV`/`OCONV` and morphology are not +//! read; words they would accept are reported as misspelt. +//! +//! No dictionary ships with DeckCraft. They are looked up in the folders the system's own spell +//! checkers use (and `DECKCRAFT_DICTIONARIES`), so whatever the user installed is used. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +type Flag = u32; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum FlagMode { + Char, + Long, + Num, +} + +#[derive(Clone, Debug)] +enum Cond { + Any, + Is(char), + In(Vec, bool), +} + +impl Cond { + fn matches(&self, c: char) -> bool { + match self { + Cond::Any => true, + Cond::Is(x) => *x == c, + Cond::In(set, neg) => set.contains(&c) != *neg, + } + } +} + +fn parse_cond(s: &str) -> Vec { + let mut out = vec![]; + let mut it = s.chars(); + while let Some(c) = it.next() { + match c { + '.' => out.push(Cond::Any), + '[' => { + let mut set = vec![]; + let mut neg = false; + for (i, d) in it.by_ref().enumerate() { + if d == ']' { + break; + } + if i == 0 && d == '^' { + neg = true; + } else { + set.push(d); + } + } + out.push(Cond::In(set, neg)); + } + c => out.push(Cond::Is(c)), + } + } + out +} + +#[derive(Clone, Debug)] +struct Affix { + flag: Flag, + cross: bool, + strip: String, + add: String, + cond: Vec, + cont: Vec, +} + +impl Affix { + /// Prefix: does `root` start with the condition? Suffix: does it end with it? + fn cond_ok(&self, root: &[char], prefix: bool) -> bool { + if self.cond.len() > root.len() { + return false; + } + if prefix { + self.cond.iter().zip(root).all(|(c, r)| c.matches(*r)) + } else { + self.cond.iter().rev().zip(root.iter().rev()).all(|(c, r)| c.matches(*r)) + } + } +} + +/// A loaded dictionary. +#[derive(Debug, Default)] +pub struct Dictionary { + words: HashMap>>, + pfx: Vec, + sfx: Vec, + try_chars: Vec, + rep: Vec<(String, String)>, + word_chars: Vec, + forbidden: Option, + need_affix: Option, + no_suggest: Option, + keep_case: Option, +} + +fn decode(bytes: &[u8], latin1: bool) -> String { + if latin1 { bytes.iter().map(|b| char::from(*b)).collect() } else { String::from_utf8_lossy(bytes).into_owned() } +} + +fn parse_flags(s: &str, mode: FlagMode) -> Vec { + match mode { + FlagMode::Char => s.chars().map(|c| c as u32).collect(), + FlagMode::Long => { + let c: Vec = s.chars().collect(); + c.chunks(2).map(|p| p.iter().fold(0u32, |a, c| a.wrapping_mul(0x11_0000).wrapping_add(*c as u32))).collect() + } + FlagMode::Num => s.split(',').filter_map(|n| n.trim().parse().ok()).collect(), + } +} + +fn one_flag(s: Option<&str>, mode: FlagMode) -> Option { + s.and_then(|s| parse_flags(s, mode).first().copied()) +} + +impl Dictionary { + /// Parse a dictionary from the bytes of its `.aff` and `.dic` files. + pub fn parse(aff: &[u8], dic: &[u8]) -> Dictionary { + let head = String::from_utf8_lossy(aff); + let latin1 = head.lines().any(|l| { + let mut w = l.split_whitespace(); + w.next() == Some("SET") && w.next().is_some_and(|e| e.to_ascii_uppercase().replace('-', "").starts_with("ISO8859")) + }); + let aff = decode(aff, latin1); + let mode = aff + .lines() + .find_map(|l| { + let mut w = l.split_whitespace(); + (w.next() == Some("FLAG")).then(|| match w.next() { + Some("long") => FlagMode::Long, + Some("num") => FlagMode::Num, + _ => FlagMode::Char, + }) + }) + .unwrap_or(FlagMode::Char); + let mut d = Dictionary::default(); + let mut lines = aff.lines().map(|l| l.split('#').next().unwrap_or("")).peekable(); + while let Some(l) = lines.next() { + let w: Vec<&str> = l.split_whitespace().collect(); + match w.as_slice() { + ["TRY", s, ..] => d.try_chars = s.chars().collect(), + ["WORDCHARS", s, ..] => d.word_chars = s.chars().collect(), + ["FORBIDDENWORD", f, ..] => d.forbidden = one_flag(Some(f), mode), + ["NEEDAFFIX", f, ..] | ["PSEUDOROOT", f, ..] => d.need_affix = one_flag(Some(f), mode), + ["NOSUGGEST", f, ..] => d.no_suggest = one_flag(Some(f), mode), + ["KEEPCASE", f, ..] => d.keep_case = one_flag(Some(f), mode), + ["REP", n] => { + for _ in 0..n.parse::().unwrap_or(0) { + let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect(); + if let [_, a, b, ..] = r.as_slice() { + d.rep.push((a.replace('_', " "), b.replace('_', " "))); + } + } + } + [kind @ ("PFX" | "SFX"), flag, cross, n] if n.parse::().is_ok() => { + let prefix = *kind == "PFX"; + let Some(flag) = one_flag(Some(flag), mode) else { continue }; + let cross = cross.eq_ignore_ascii_case("y"); + for _ in 0..n.parse::().unwrap_or(0) { + let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect(); + let [_, _, strip, add, rest @ ..] = r.as_slice() else { continue }; + let (add, cont) = match add.split_once('/') { + Some((a, c)) => (a, parse_flags(c, mode)), + None => (*add, vec![]), + }; + let a = Affix { + flag, + cross, + strip: if *strip == "0" { String::new() } else { strip.to_string() }, + add: if add == "0" { String::new() } else { add.to_string() }, + cond: parse_cond(rest.first().copied().unwrap_or(".")), + cont, + }; + if prefix { d.pfx.push(a) } else { d.sfx.push(a) } + } + } + _ => {} + } + } + let dic = decode(dic, latin1); + for (i, l) in dic.lines().enumerate() { + if i == 0 && l.trim().parse::().is_ok() { + continue; + } + let entry = l.split(['\t']).next().unwrap_or("").trim_end(); + // Morphological fields follow a space (`word/flags po:noun`). + let entry = entry.split(' ').next().unwrap_or(""); + if entry.is_empty() { + continue; + } + let (word, flags) = match entry.split_once('/') { + Some((w, f)) if !w.is_empty() => (w, parse_flags(f, mode)), + _ => (entry, vec![]), + }; + d.words.entry(word.to_string()).or_default().push(flags); + } + d + } + + /// Add `word` (no flags), as a user's own dictionary entry. + pub fn add_word(&mut self, word: &str) { + self.words.entry(word.to_string()).or_default().push(vec![]); + } + + fn has(&self, flags: &[Flag], f: Option) -> bool { + f.is_some_and(|f| flags.contains(&f)) + } + + /// `word` as a root carrying all of `need` (and not forbidden). + fn root(&self, word: &str, need: &[Flag], affixed: bool) -> bool { + self.words.get(word).is_some_and(|hs| { + hs.iter().any(|f| !self.has(f, self.forbidden) && (affixed || !self.has(f, self.need_affix)) && need.iter().all(|n| f.contains(n))) + }) + } + + fn forbidden_word(&self, word: &str) -> bool { + self.words.get(word).is_some_and(|hs| hs.iter().any(|f| self.has(f, self.forbidden))) + } + + /// Strip suffixes from `w` (one, or two through continuation flags); `extra` flags must also be + /// on the root, `cross` means the suffix must allow a prefix. + fn suffixed(&self, w: &[char], extra: &[Flag], cross: bool) -> bool { + for s in &self.sfx { + if cross && !s.cross { + continue; + } + let Some(stem) = strip_end(w, &s.add, &s.strip) else { continue }; + if !s.cond_ok(&stem, false) { + continue; + } + let root: String = stem.iter().collect(); + let mut need = extra.to_vec(); + need.push(s.flag); + if self.root(&root, &need, true) { + return true; + } + // A second suffix whose continuation allows this one (`walk/A` + `ing/B`…). + for s2 in &self.sfx { + if !s.cont.is_empty() || !s2.cont.contains(&s.flag) { + continue; + } + let Some(stem2) = strip_end(&stem, &s2.add, &s2.strip) else { continue }; + if s2.cond_ok(&stem2, false) { + let mut need = extra.to_vec(); + need.push(s2.flag); + if self.root(&stem2.iter().collect::(), &need, true) { + return true; + } + } + } + } + false + } + + fn check_exact(&self, word: &str) -> bool { + if self.forbidden_word(word) { + return false; + } + if self.root(word, &[], false) { + return true; + } + let w: Vec = word.chars().collect(); + if self.suffixed(&w, &[], false) { + return true; + } + for p in &self.pfx { + let Some(stem) = strip_start(&w, &p.add, &p.strip) else { continue }; + if !p.cond_ok(&stem, true) { + continue; + } + if self.root(&stem.iter().collect::(), &[p.flag], true) { + return true; + } + if p.cross && self.suffixed(&stem, &[p.flag], true) { + return true; + } + } + false + } + + /// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass. + pub fn check(&self, word: &str) -> bool { + if word.is_empty() || word.chars().all(|c| !c.is_alphabetic()) { + return true; + } + if self.check_exact(word) { + return true; + } + let lower = word.to_lowercase(); + let upper_tail = word.chars().skip(1).all(|c| !c.is_lowercase()); + let cap_only = word.chars().next().is_some_and(char::is_uppercase) && word.chars().skip(1).all(|c| !c.is_uppercase()); + let keep = |w: &str| self.words.get(w).is_some_and(|hs| hs.iter().any(|f| self.has(f, self.keep_case))); + if (cap_only || upper_tail) && !keep(&lower) && self.check_exact(&lower) { + return true; + } + if upper_tail && !cap_only { + let cap = capitalise(&lower); + if !keep(&cap) && self.check_exact(&cap) { + return true; + } + } + // A trailing full stop on an abbreviation (`etc.`) is part of the word in some dictionaries. + false + } + + fn suggestible(&self, w: &str) -> bool { + self.check(w) && !self.words.get(w).is_some_and(|hs| hs.iter().all(|f| self.has(f, self.no_suggest) && !f.is_empty())) + } + + /// Up to `n` corrections for a misspelt `word`, best first. + pub fn suggest(&self, word: &str, n: usize) -> Vec { + let cap = word.chars().next().is_some_and(char::is_uppercase); + let lower = if cap && word.chars().skip(1).all(|c| !c.is_uppercase()) { word.to_lowercase() } else { word.to_string() }; + let w: Vec = lower.chars().collect(); + let mut out: Vec = vec![]; + let mut seen = HashSet::new(); + let mut push = |s: String, out: &mut Vec| { + if s != lower && seen.insert(s.to_lowercase()) && s.split(' ').all(|p| self.suggestible(p)) { + out.push(s); + } + }; + for (a, b) in &self.rep { + let mut from = 0; + while let Some(i) = lower.get(from..).and_then(|t| t.find(a.as_str())) { + let at = from + i; + push(format!("{}{}{}", &lower[..at], b, &lower[at + a.len()..]), &mut out); + from = at + a.len().max(1); + } + } + let try_chars: Vec = if self.try_chars.is_empty() { "esianrtolcdugmphbyfvkwzxjq".chars().collect() } else { self.try_chars.clone() }; + let s = |v: &[char]| v.iter().collect::(); + for i in 0..w.len().saturating_sub(1) { + let mut v = w.clone(); + v.swap(i, i + 1); + push(s(&v), &mut out); + } + for i in 0..w.len() { + let mut v = w.clone(); + v.remove(i); + push(s(&v), &mut out); + } + for i in 0..w.len() { + for c in &try_chars { + if *c != w[i] { + let mut v = w.clone(); + v[i] = *c; + push(s(&v), &mut out); + } + } + } + for i in 0..=w.len() { + for c in &try_chars { + let mut v = w.clone(); + v.insert(i, *c); + push(s(&v), &mut out); + } + } + for i in 1..w.len() { + push(format!("{} {}", s(&w[..i]), s(&w[i..])), &mut out); + } + out.truncate(n); + if cap { + out = out.into_iter().map(|x| capitalise(&x)).collect(); + } + out + } + + /// Characters that belong inside words besides letters and digits (the `WORDCHARS` list). + pub fn word_chars(&self) -> &[char] { + &self.word_chars + } +} + +fn capitalise(s: &str) -> String { + let mut c = s.chars(); + c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default() +} + +fn strip_end(w: &[char], add: &str, strip: &str) -> Option> { + let add: Vec = add.chars().collect(); + if w.len() <= add.len() && strip.is_empty() || !w.ends_with(&add) { + return None; + } + let mut v = w.get(..w.len() - add.len())?.to_vec(); + v.extend(strip.chars()); + (!v.is_empty()).then_some(v) +} + +fn strip_start(w: &[char], add: &str, strip: &str) -> Option> { + let add: Vec = add.chars().collect(); + if w.len() <= add.len() && strip.is_empty() || !w.starts_with(&add) { + return None; + } + let mut v: Vec = strip.chars().collect(); + v.extend_from_slice(w.get(add.len()..)?); + (!v.is_empty()).then_some(v) +} + +/// The folders searched for dictionaries, in order. +pub fn search_dirs() -> Vec { + let mut out = vec![]; + if let Some(v) = std::env::var_os("DECKCRAFT_DICTIONARIES") { + out.extend(std::env::split_paths(&v)); + } + let home = std::env::var_os("HOME").map(PathBuf::from); + if cfg!(target_os = "macos") { + out.extend(home.iter().map(|h| h.join("Library/Application Support/DeckCraft/Dictionaries"))); + out.extend(home.iter().map(|h| h.join("Library/Spelling"))); + out.push("/Library/Spelling".into()); + } else if cfg!(target_os = "windows") { + out.extend(std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("DeckCraft").join("Dictionaries"))); + } else { + let data = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| home.as_ref().map(|h| h.join(".local/share"))); + out.extend(data.iter().map(|d| d.join("deckcraft/dictionaries"))); + out.extend(data.iter().map(|d| d.join("hunspell"))); + for d in ["/usr/share/hunspell", "/usr/share/myspell", "/usr/share/myspell/dicts", "/usr/local/share/hunspell", "/app/share/hunspell"] { + out.push(d.into()); + } + } + out +} + +/// `en-US` → `en_US`; the language alone (`en`) as a fallback. +fn names(lang: &str) -> (String, String) { + let full = lang.replace('-', "_"); + let base = full.split('_').next().unwrap_or("").to_lowercase(); + (full, base) +} + +/// The `.aff`/`.dic` pair for `lang` in `dirs`: the exact locale first, then any of its language. +pub fn find_in(dirs: &[PathBuf], lang: &str) -> Option<(PathBuf, PathBuf)> { + let (full, base) = names(lang); + let pair = |d: &Path, stem: &str| { + let (a, b) = (d.join(format!("{stem}.aff")), d.join(format!("{stem}.dic"))); + (a.is_file() && b.is_file()).then_some((a, b)) + }; + for d in dirs { + if let Some(p) = pair(d, &full) { + return Some(p); + } + } + for d in dirs { + if let Some(p) = pair(d, &base) { + return Some(p); + } + let Ok(rd) = std::fs::read_dir(d) else { continue }; + let mut stems: Vec = rd + .filter_map(|e| e.ok()?.file_name().into_string().ok()) + .filter_map(|n| n.strip_suffix(".aff").map(str::to_string)) + .filter(|n| n.to_lowercase().starts_with(&format!("{base}_"))) + .collect(); + stems.sort(); + if let Some(p) = stems.iter().find_map(|s| pair(d, s)) { + return Some(p); + } + } + None +} + +type Cache = Mutex>>>; + +fn cache() -> &'static Cache { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(Default::default) +} + +/// The dictionary for `lang` (a BCP-47 tag such as `en-US`), loaded once per process. +pub fn dictionary(lang: &str) -> Option> { + let cache = cache(); + let key = lang.to_string(); + if let Ok(c) = cache.lock() + && let Some(d) = c.get(&key) + { + return d.clone(); + } + let loaded = find_in(&search_dirs(), lang).and_then(|(a, d)| { + let (a, d) = (std::fs::read(a).ok()?, std::fs::read(d).ok()?); + Some(Arc::new(Dictionary::parse(&a, &d))) + }); + if let Ok(mut c) = cache.lock() { + c.insert(key, loaded.clone()); + } + loaded +} + +/// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself). +pub fn install(lang: &str, d: Dictionary) { + if let Ok(mut c) = cache().lock() { + c.insert(lang.to_string(), Some(Arc::new(d))); + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + // A contributor-original miniature dictionary in Hunspell format. + pub(crate) const AFF: &str = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nFORBIDDENWORD !\nNEEDAFFIX ?\nKEEPCASE K\n\ +REP 1\nREP f ph\n\ +PFX U Y 1\nPFX U 0 un .\n\ +SFX S Y 2\nSFX S y ies [^aeiou]y\nSFX S 0 s [^y]\n\ +SFX G Y 2\nSFX G e ing e\nSFX G 0 ing [^e]\n\ +SFX D Y 1\nSFX D 0 ed/S .\n"; + pub(crate) const DIC: &str = "9\nslide/S\nmake/G\nhappy\ncity/S\ndo/U\nwork/USG\nphoto/S\nNASA/K\nfoo/?S\n"; + + fn d() -> Dictionary { + Dictionary::parse(AFF.as_bytes(), DIC.as_bytes()) + } + + #[test] + fn roots_affixes_and_cross_products_check() { + let d = d(); + for w in ["slide", "slides", "making", "cities", "undo", "unworks", "working", "Slide", "SLIDES", "NASA", "foos"] { + assert!(d.check(w), "{w}"); + } + for w in ["slidez", "makeing", "citys", "unslide", "Nasa", "nasa", "foo", "teh"] { + assert!(!d.check(w), "{w}"); + } + } + + #[test] + fn suggestions_come_from_edits_and_replacements() { + let d = d(); + assert_eq!(d.suggest("sldie", 3).first().map(String::as_str), Some("slide")); + assert!(d.suggest("foto", 5).contains(&"photo".to_string())); + assert!(d.suggest("Slidse", 5).contains(&"Slides".to_string())); + assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string())); + } + + #[test] + fn long_and_numeric_flags_and_latin1() { + let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n"); + assert!(d.check("cats")); + let d = Dictionary::parse(b"FLAG num\nSFX 101 Y 1\nSFX 101 0 s .\n", b"1\ndog/7,101\n"); + assert!(d.check("dogs")); + let d = Dictionary::parse(b"SET ISO8859-1\n", b"1\ncaf\xe9\n"); + assert!(d.check("café")); + } + + #[test] + fn dictionaries_are_found_by_locale_then_language() { + let dir = std::env::temp_dir().join(format!("deckcraft-dict-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + for n in ["en_GB", "de_DE"] { + let _ = std::fs::write(dir.join(format!("{n}.aff")), ""); + let _ = std::fs::write(dir.join(format!("{n}.dic")), ""); + } + let dirs = vec![dir.clone()]; + let stem = |l: &str| find_in(&dirs, l).and_then(|(a, _)| a.file_stem().map(|s| s.to_string_lossy().into_owned())); + assert_eq!(stem("de-DE").as_deref(), Some("de_DE")); + assert_eq!(stem("en-US").as_deref(), Some("en_GB")); + assert_eq!(stem("fr-FR"), None); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..f052d955 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,39 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn spelling_uses_a_dictionary_suggests_and_changes() { + crate::spell::install("xx-TEST", crate::spell::Dictionary::parse(crate::spell::tests::AFF.as_bytes(), crate::spell::tests::DIC.as_bytes())); + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "Making sldie photos, sldie"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + let v = s.execute("review.spelling", &json!({"lang": "xx-TEST"})).unwrap(); + let list = v.as_array().unwrap(); + assert_eq!(list.len(), 2, "{v}"); + assert_eq!(list[0]["word"], "sldie"); + assert_eq!(list[0]["suggestions"][0], "slide"); + assert!(s.execute("review.spelling", &json!({"lang": "xx-TEST", "ignore": ["SLDIE"]})).unwrap().as_array().unwrap().is_empty()); + let c = s.execute("review.spellingChange", &json!({"shape": id, "word": "sldie", "to": "slide"})).unwrap(); + assert_eq!(c["changed"], 1); + assert_eq!(s.execute("review.spelling", &json!({"lang": "xx-TEST"})).unwrap().as_array().unwrap().len(), 1); + let c = s.execute("review.spellingChange", &json!({"word": "sldie", "to": "slide", "all": true})).unwrap(); + assert_eq!(c["changed"], 1); + assert!(s.execute("review.spelling", &json!({"lang": "xx-TEST"})).unwrap().as_array().unwrap().is_empty()); + // Whole words only, and one undo step per change. + assert_eq!(s.execute("review.spellingChange", &json!({"word": "slid", "to": "x", "all": true})).unwrap()["changed"], 0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("review.spelling", &json!({"lang": "xx-TEST"})).unwrap().as_array().unwrap().len(), 1); +} + +#[test] +fn spelling_without_a_dictionary_falls_back_to_common_misspellings() { + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "Teh qzxv"})).unwrap(); + let v = s.execute("review.spelling", &json!({"lang": "zz-NONE"})).unwrap(); + assert_eq!(v.as_array().map(Vec::len), Some(1)); + assert_eq!(v[0]["word"], "Teh"); + let d = s.execute("review.dictionary", &json!({"lang": "zz-NONE"})).unwrap(); + assert!(d["found"].is_null()); + assert_eq!(d["available"], false); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..cca5dc5d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -510,19 +510,77 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } "spelling" => { - let v = app.session.execute("review.spelling", &json!({})).unwrap_or_default(); + // Words skipped with Ignore All while the dialog is open, one per line. + let ignored = d.get("ignore", ""); + let ignore: Vec<&str> = ignored.lines().filter(|l| !l.is_empty()).collect(); + let v = app.session.execute("review.spelling", &json!({"ignore": ignore})).unwrap_or_default(); let list = v.as_array().cloned().unwrap_or_default(); + let info = app.session.execute("review.dictionary", &json!({})).unwrap_or_default(); + let available = info.get("available").and_then(Value::as_bool).unwrap_or(false); + match info.get("found").and_then(Value::as_str) { + Some(f) if available => { + ui.label(egui::RichText::new(format!("Dictionary: {}", f.rsplit(['/', '\\']).next().unwrap_or(f))).small().weak()) + } + _ if available => { + ui.label(egui::RichText::new(format!("Dictionary: {}", info.get("lang").and_then(Value::as_str).unwrap_or(""))).small().weak()) + } + _ => ui.label( + egui::RichText::new(format!( + "No {} dictionary is installed, so only common misspellings are found. Put a Hunspell .aff/.dic pair in {}.", + info.get("lang").and_then(Value::as_str).unwrap_or("en-US"), + info.get("folders") + .and_then(Value::as_array) + .and_then(|a| a.get(1).or(a.first())) + .and_then(Value::as_str) + .unwrap_or("a dictionaries folder") + )) + .small() + .weak(), + ), + }; if list.is_empty() { ui.label("The spelling check is complete. No issues found."); } - for item in &list { - ui.horizontal(|ui| { + let mut add_ignore = None; + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for item in &list { let w = item.get("word").and_then(Value::as_str).unwrap_or(""); - ui.label(format!("Slide {}: “{w}”", item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1)); - if ui.small_button("Go to").clicked() { - let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()})); + let slide = item.get("slide").cloned().unwrap_or_default(); + let shape = item.get("shape").cloned().unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(format!("Slide {}: “{w}”", slide.as_u64().unwrap_or(0) + 1)); + if ui.small_button("Go to").clicked() { + let _ = app.run("slide.go", json!({"index": slide})); + let _ = app.run("edit.select", json!({"ids": [shape]})); + } + if ui.small_button("Ignore All").clicked() { + add_ignore = Some(w.to_string()); + } + }); + let sug: Vec = item + .get("suggestions") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + if !sug.is_empty() { + ui.horizontal_wrapped(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new("Change to:").weak()); + for to in &sug { + let r = ui.small_button(to.as_str()).on_hover_text("Click to change this one; Shift-click to change all"); + if r.clicked() { + let all = ui.input(|i| i.modifiers.shift); + let p = + if all { json!({"word": w, "to": to, "all": true}) } else { json!({"shape": shape, "word": w, "to": to}) }; + run(app, "review.spellingChange", p); + } + } + }); } - }); + } + }); + if let Some(w) = add_ignore { + d.fields.insert("ignore".into(), format!("{ignored}{w}\n")); } let (ok, cancel) = buttons(ui, "Close"); ok || cancel @@ -829,3 +887,7 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { Stroke::NONE, )); } + +#[cfg(test)] +#[path = "spelling_tests.rs"] +mod spelling_tests; diff --git a/crates/ui-egui/src/spelling_tests.rs b/crates/ui-egui/src/spelling_tests.rs new file mode 100644 index 00000000..67e3ee64 --- /dev/null +++ b/crates/ui-egui/src/spelling_tests.rs @@ -0,0 +1,46 @@ +//! The Spelling dialog with a dictionary: suggestions change the word, Ignore All hides it. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn spelling_dialog_changes_and_ignores_words() { + let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nSFX S Y 1\nSFX S 0 s .\n"; + let dic = "6\nthe/S\nslide/S\nquarter/S\nplan/S\nour\nfor\n"; + deckcraft_engine::spell::install("en-US", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes())); + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 500, 120], "text": "Our plna for teh quarter"})).unwrap(); + app.dialog = Some(crate::dialogs::Dialog::new("spelling")); + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 700.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT"), h.render()) { + let _ = img.save(path); + } + h.get_by_label("plan").click(); + h.run_steps(3); + let text = |h: &Harness<'_, SlideApp>| { + let st = h.state().session.active().unwrap(); + let mut out = String::new(); + deckcraft_model::walk(&st.doc.slides[0].shapes, &mut |s, _| { + if let Some(t) = &s.text { + out += &t.text(); + } + }); + out + }; + assert!(text(&h).contains("Our plan for teh"), "{}", text(&h)); + h.get_by_label("Ignore All").click(); + h.run_steps(3); + assert!(h.query_by_label_contains("No issues found").is_some()); +} diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..f965a5d6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -175,9 +175,13 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * rigs and materials, Format Shape page). ### Proofing: spelling, thesaurus, AutoCorrect -- **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings - (`crates/engine/src/cmd/review.rs`); no dictionaries, no thesaurus, no AutoCorrect. -- **Estimate:** 8–14 h (Hunspell-format dictionaries under an open licence per language). +- **Evidence:** `review.spelling` checks with Hunspell-format dictionaries the system already has + (`crates/engine/src/spell.rs`: affixes, cross products, one level of continuation, case rules, + suggestions; no compounding) and the Spelling dialog changes or ignores words, but none ship with + DeckCraft, so without one installed (always on Windows and the web) only a short list of common + misspellings is found. No red squiggles while typing, no per-run language, no Add to Dictionary. + Thesaurus (#549) and AutoCorrect (#521) have open PRs. +- **Estimate:** 4–8 h (bundle openly licensed dictionaries, squiggles, custom dictionary, compounding). ### Equations - **Evidence:** checklist row missing; PR #55 (OMML editor, +10.5k lines) is open. @@ -256,6 +260,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Spelling checks with installed Hunspell dictionaries and suggests corrections | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |