diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 822d6e92..54751567 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -16,16 +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"), "{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!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{lang?, ignore?:[word]} → [{slide, shape, para, start, end, word, lang, suggestions}] (each run in its own language, lang? for runs without one; 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)", + "{shape, word, to, all?, para?, start?} → {changed} (replace the whole word in that shape's text, the one at para/start, or in every shape with all)", has_doc, spelling_change ), + cmd!(noundo "review.addToDictionary", "Add to Dictionary", ["Review", "Proofing"], None, "{word} → {word, file} (append to the user dictionary file; not an undo step)", always, add_to_dictionary), 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, _| { @@ -226,37 +227,30 @@ fn check_lang(doc: &deckcraft_model::Presentation, p: &Value) -> String { 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 default = 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 dicts: std::collections::HashMap>> = Default::default(); let mut out = vec![]; for (i, sl) in st.doc.slides.iter().enumerate() { deckcraft_model::walk(&sl.shapes, &mut |sh, _| { 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})); + for (pi, para) in t.paragraphs.iter().enumerate() { + for w in crate::spell::paragraph_words(para, &default) { + if ignore.contains(&w.text.to_lowercase()) { + continue; + } + let dict = dicts.entry(w.lang.clone()).or_insert_with(|| crate::spell::dictionary(&w.lang)).clone(); + let bad = match &dict { + Some(d) => !crate::spell::is_correct(d, &w.text), + None => COMMON.contains(&w.text.to_lowercase().as_str()), + }; + if bad { + let sug = dict.as_ref().map(|d| d.suggest(&w.text, 5)).unwrap_or_default(); + out.push(json!({"slide": i, "shape": sh.id, "para": pi, "start": w.start, "end": w.end, "word": w.text, "lang": w.lang, "suggestions": sug})); + } } } }); @@ -264,6 +258,12 @@ fn spelling(s: &mut Session, p: &Value) -> Result { Ok(Value::Array(out)) } +fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result { + let word = str_param(p, "word").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.addToDictionary", "missing `word`"))?; + crate::spell::add_user_word(word).map_err(|e| bad("review.addToDictionary", e.to_string()))?; + Ok(json!({"word": word.trim(), "file": crate::spell::user_dictionary_path().map(|p| p.display().to_string())})) +} + /// 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 == '’'; @@ -295,6 +295,29 @@ fn spelling_change(s: &mut Session, p: &Value) -> Result { if shape.is_none() && !all { return Err(bad("review.spellingChange", "give `shape`, or `all: true`")); } + // One exact occurrence (a squiggled word): paragraph `para`, from character `start`. + if let (Some(id), Some(para), Some(start)) = (shape, usize_param(p, "para"), usize_param(p, "start")) { + let id = deckcraft_model::ShapeId(u32::try_from(id).map_err(|_| bad("review.spellingChange", "bad `shape`"))?); + let end = start + word.chars().count(); + let slide = { + let st = s.doc()?; + let found = st.doc.slides.iter().position(|sl| { + deckcraft_model::find_shape(&sl.shapes, id) + .and_then(|sh| sh.text.as_ref()?.paragraphs.get(para)) + .is_some_and(|pp| pp.text().chars().skip(start).take(end - start).collect::() == word) + }); + found.ok_or_else(|| bad("review.spellingChange", "the word isn't there"))? + }; + s.edit(|doc, _| { + let sl = doc.slides.get_mut(slide).ok_or_else(|| bad("review.spellingChange", "no slide"))?; + let sh = deckcraft_model::find_shape_mut(&mut Arc::make_mut(sl).shapes, id).ok_or_else(|| bad("review.spellingChange", "no shape"))?; + if let Some(pp) = sh.text.as_mut().and_then(|t| t.paragraphs.get_mut(para)) { + replace_chars(pp, start, end, &to); + } + Ok(()) + })?; + return Ok(json!({"changed": 1})); + } // Nothing to change makes no undo step. let mut any = false; let st = s.doc()?; @@ -339,6 +362,25 @@ fn spelling_change(s: &mut Session, p: &Value) -> Result { Ok(json!({"changed": changed})) } +/// Replace characters `start..end` of `p` with `to`, in the run where they start (later runs lose +/// the rest of the range). +fn replace_chars(p: &mut deckcraft_model::Paragraph, start: usize, end: usize, to: &str) { + let mut at = 0; + let mut placed = false; + for r in &mut p.runs { + let n = r.char_len(); + let (a, b) = (start.max(at), end.min(at + n)); + if a < b && r.kind == deckcraft_model::text::RunKind::Text { + let byte = |c: usize| r.text.char_indices().nth(c).map_or(r.text.len(), |(i, _)| i); + let (x, y) = (byte(a - at), byte(b - at)); + r.text.replace_range(x..y, if placed { "" } else { to }); + placed = true; + } + at += n; + } + p.runs.retain(|r| !(r.kind == deckcraft_model::text::RunKind::Text && r.text.is_empty())); +} + fn walk_text_mut(shapes: &mut [deckcraft_model::Shape], depth: usize, f: &mut dyn FnMut(&mut deckcraft_model::Shape)) { if depth > 64 { return; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1b2cb2d2..cae4f319 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -485,6 +485,27 @@ fn commit(s: &mut Session, p: &Value) -> Result { /// Underlines under an input method's composition (#101), in slide coordinates: `(from, to, /// thick)` per line, thick under the part being converted. Empty without a composition or when /// the edited text isn't laid out on the slide (notes, table cells). +/// Underlines (slide coordinates) under character ranges `(para, start, end)` of shape `id`'s +/// text as laid out on the current slide: where spelling squiggles go. +pub fn range_underlines( + st: &DocState, + id: deckcraft_model::ShapeId, + ranges: &[(usize, usize, usize)], +) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point)> { + let Some(sh) = st.shape(id) else { return vec![] }; + let Some(l) = layout_for(st, &TextSel { shape: id, ..Default::default() }) else { return vec![] }; + let x = xfrm_of(&st.doc, &st.selection, sh); + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + let mut out = vec![]; + for &(para, a, b) in ranges { + for (x0, x1, y) in l.composition_underlines(deckcraft_text::Pos { para, ch: a }, deckcraft_text::Pos { para, ch: b }) { + out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y))); + } + } + out +} + pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> { let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] }; let Some(sh) = st.shape(t.shape) else { return vec![] }; diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs index 4c089812..bf0e1660 100644 --- a/crates/engine/src/spell.rs +++ b/crates/engine/src/spell.rs @@ -103,6 +103,13 @@ pub struct Dictionary { need_affix: Option, no_suggest: Option, keep_case: Option, + only_in_compound: Option, + compound: Option, + compound_begin: Option, + compound_middle: Option, + compound_end: Option, + compound_min: usize, + compound_max: usize, } fn decode(bytes: &[u8], latin1: bool) -> String { @@ -144,7 +151,7 @@ impl Dictionary { }) }) .unwrap_or(FlagMode::Char); - let mut d = Dictionary::default(); + let mut d = Dictionary { compound_min: 3, ..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(); @@ -155,6 +162,13 @@ impl Dictionary { ["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), + ["ONLYINCOMPOUND", f, ..] => d.only_in_compound = one_flag(Some(f), mode), + ["COMPOUNDFLAG", f, ..] => d.compound = one_flag(Some(f), mode), + ["COMPOUNDBEGIN", f, ..] => d.compound_begin = one_flag(Some(f), mode), + ["COMPOUNDMIDDLE", f, ..] => d.compound_middle = one_flag(Some(f), mode), + ["COMPOUNDEND", f, ..] | ["COMPOUNDLAST", f, ..] => d.compound_end = one_flag(Some(f), mode), + ["COMPOUNDMIN", n, ..] => d.compound_min = n.parse::().unwrap_or(3).max(1), + ["COMPOUNDWORDMAX", n, ..] => d.compound_max = n.parse().unwrap_or(0), ["REP", n] => { for _ in 0..n.parse::().unwrap_or(0) { let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect(); @@ -220,7 +234,12 @@ impl Dictionary { /// `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))) + hs.iter().any(|f| { + !self.has(f, self.forbidden) + && (affixed || !self.has(f, self.need_affix)) + && !self.has(f, self.only_in_compound) + && need.iter().all(|n| f.contains(n)) + }) }) } @@ -263,6 +282,53 @@ impl Dictionary { false } + /// A root (or, for the last part, a suffixed root) carrying `flag`, as a compound part. + fn compound_part(&self, part: &[char], flag: Flag, last: bool) -> bool { + let word: String = part.iter().collect(); + let ok = self.words.get(&word).is_some_and(|hs| hs.iter().any(|f| !self.has(f, self.forbidden) && f.contains(&flag))); + ok || (last && self.suffixed_compound_end(part, flag)) + } + + /// The last part of a compound with a suffix: the root must carry the compound `flag`. + fn suffixed_compound_end(&self, w: &[char], flag: Flag) -> bool { + self.sfx.iter().any(|s| { + strip_end(w, &s.add, &s.strip).is_some_and(|stem| { + s.cond_ok(&stem, false) + && self + .words + .get(&stem.iter().collect::()) + .is_some_and(|hs| hs.iter().any(|f| !self.has(f, self.forbidden) && f.contains(&flag) && f.contains(&s.flag))) + }) + }) + } + + /// `w` made of dictionary parts allowed in compounds (`COMPOUNDFLAG`, or the begin, middle and + /// end flags), each at least `COMPOUNDMIN` characters long. + fn compounded(&self, w: &[char], index: usize) -> bool { + if self.compound.is_none() && self.compound_end.is_none() { + return false; + } + let min = self.compound_min.max(1); + if index >= 8 || (self.compound_max > 0 && index + 1 >= self.compound_max) || w.len() < 2 * min { + return false; + } + let role = if index == 0 { self.compound_begin } else { self.compound_middle }; + for i in min..=w.len() - min { + let (head, rest) = w.split_at(i); + let head_ok = [self.compound, role].into_iter().flatten().any(|f| self.compound_part(head, f, false)); + if !head_ok { + continue; + } + if [self.compound, self.compound_end].into_iter().flatten().any(|f| self.compound_part(rest, f, true)) { + return true; + } + if self.compounded(rest, index + 1) { + return true; + } + } + false + } + fn check_exact(&self, word: &str) -> bool { if self.forbidden_word(word) { return false; @@ -286,7 +352,7 @@ impl Dictionary { return true; } } - false + self.compounded(&w, 0) } /// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass. @@ -494,6 +560,168 @@ pub fn dictionary(lang: &str) -> Option> { loaded } +/// Where Add to Dictionary keeps words: `user.dic` in DeckCraft's own dictionaries folder, one +/// word per line (the Hunspell `.dic` format without a count). +pub fn user_dictionary_path() -> Option { + if let Ok(p) = user_override().lock() + && let Some(p) = p.clone() + { + return Some(p); + } + let home = std::env::var_os("HOME").map(PathBuf::from); + let dir = if cfg!(target_os = "macos") { + home.map(|h| h.join("Library/Application Support/DeckCraft/Dictionaries")) + } else if cfg!(target_os = "windows") { + std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("DeckCraft").join("Dictionaries")) + } else { + std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| home.map(|h| h.join(".local/share"))) + .map(|d| d.join("deckcraft/dictionaries")) + }; + dir.map(|d| d.join("user.dic")) +} + +fn user_override() -> &'static Mutex> { + static P: OnceLock>> = OnceLock::new(); + P.get_or_init(Default::default) +} + +struct UserWords { + words: Option>, + generation: u64, +} + +fn user_state() -> &'static Mutex { + static U: OnceLock> = OnceLock::new(); + U.get_or_init(|| Mutex::new(UserWords { words: None, generation: 0 })) +} + +/// Keep the user dictionary at `path` instead (tests, or a portable install). +pub fn set_user_dictionary_path(path: PathBuf) { + if let Ok(mut p) = user_override().lock() { + *p = Some(path); + } + if let Ok(mut u) = user_state().lock() { + u.words = None; + u.generation += 1; + } +} + +/// Bumped whenever the user dictionary changes, so cached results can be dropped. +pub fn user_generation() -> u64 { + user_state().lock().map(|u| u.generation).unwrap_or(0) +} + +fn with_user(f: impl FnOnce(&HashSet) -> T) -> Option { + let mut u = user_state().lock().ok()?; + if u.words.is_none() { + let text = user_dictionary_path().and_then(|p| std::fs::read_to_string(p).ok()).unwrap_or_default(); + u.words = Some(text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(str::to_string).collect()); + } + u.words.as_ref().map(f) +} + +/// Add `word` to the user dictionary file (and to checks from now on). +pub fn add_user_word(word: &str) -> std::io::Result<()> { + use std::io::Write; + let word = word.trim(); + if word.is_empty() || word.contains(['\n', '\r']) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "not a word")); + } + if with_user(|u| u.contains(word)).unwrap_or(false) { + return Ok(()); + } + let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?; + if let Some(d) = path.parent() { + std::fs::create_dir_all(d)?; + } + let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?; + writeln!(f, "{word}")?; + if let Ok(mut u) = user_state().lock() { + if let Some(w) = u.words.as_mut() { + w.insert(word.to_string()); + } + u.generation += 1; + } + Ok(()) +} + +/// Is `word` right: in the user dictionary (as written, or lower-case), else by `d`. +pub fn is_correct(d: &Dictionary, word: &str) -> bool { + with_user(|u| u.contains(word) || u.contains(&word.to_lowercase())).unwrap_or(false) || d.check(word) +} + +/// A word of a paragraph: its characters `start..end` (paragraph character offsets, as text +/// positions count them) and the language of the run it starts in. +#[derive(Clone, Debug, PartialEq)] +pub struct Word { + pub start: usize, + pub end: usize, + pub text: String, + pub lang: String, +} + +/// The words of `p`, each with its run's language (`lang` on the run, else `default`). Words +/// with digits are skipped; apostrophes inside a word belong to it; hyphenated words are checked +/// part by part. +pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec { + let mut chars: Vec<(char, usize)> = vec![]; + for (ri, r) in p.runs.iter().enumerate() { + chars.extend(r.content().chars().map(|c| (c, ri))); + } + let lang_of = |ri: usize| p.runs.get(ri).and_then(|r| r.props.lang.clone()).filter(|l| !l.is_empty()).unwrap_or_else(|| default.to_string()); + let inside = |c: char| c.is_alphanumeric() || c == '\'' || c == '’' || c == '-'; + let mut out = vec![]; + let mut i = 0; + while i < chars.len() { + if !inside(chars[i].0) { + i += 1; + continue; + } + let start = i; + while i < chars.len() && inside(chars[i].0) { + i += 1; + } + let (mut a, mut b) = (start, i); + while a < b && !chars[a].0.is_alphanumeric() { + a += 1; + } + while b > a && !chars[b - 1].0.is_alphanumeric() { + b -= 1; + } + let text: String = chars[a..b].iter().map(|c| c.0).collect(); + if text.is_empty() || text.chars().any(|c| c.is_ascii_digit()) || !text.chars().any(char::is_alphabetic) { + continue; + } + let mut k = a; + for part in text.split('-') { + let n = part.chars().count(); + if !part.is_empty() + && let Some(c) = chars.get(k) + { + out.push(Word { start: k, end: k + n, text: part.to_string(), lang: lang_of(c.1) }); + } + k += n + 1; + } + } + out +} + +/// The misspelt words of `p`, each checked in its own language (languages with no dictionary +/// installed are skipped), leaving out `ignore` (lower-case). +pub fn misspelt(p: &deckcraft_model::Paragraph, default: &str, ignore: &[String]) -> Vec { + let mut dicts: HashMap>> = HashMap::new(); + paragraph_words(p, default) + .into_iter() + .filter(|w| !ignore.contains(&w.text.to_lowercase())) + .filter(|w| { + let d = dicts.entry(w.lang.clone()).or_insert_with(|| dictionary(&w.lang)); + d.as_ref().is_some_and(|d| !is_correct(d, &w.text)) + }) + .collect() +} + /// 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() { @@ -538,6 +766,43 @@ SFX D Y 1\nSFX D 0 ed/S .\n"; assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string())); } + #[test] + fn compounds_join_flagged_parts() { + let aff = "COMPOUNDFLAG X\nCOMPOUNDMIN 3\nONLYINCOMPOUND O\nSFX S Y 1\nSFX S 0 en .\n"; + let d = Dictionary::parse(aff.as_bytes(), b"4\nhaus/X\ntuer/XS\nschluessel/X\nfugs/OX\n".as_slice()); + for w in ["haustuer", "haustueren", "haustuerschluessel", "haus", "hausfugstuer"] { + assert!(d.check(w), "{w}"); + } + for w in ["hausx", "fugs", "tuerhausz"] { + assert!(!d.check(w), "{w}"); + } + } + + #[test] + fn runs_give_words_their_language() { + use deckcraft_model::{Paragraph, Run, RunProps}; + let p = Paragraph { + runs: vec![ + Run::with("Hello wor", RunProps { lang: Some("en-GB".into()), ..Default::default() }), + Run::with("ld, Grüße well-known 3rd l'été", RunProps { lang: Some("de-DE".into()), ..Default::default() }), + ], + ..Default::default() + }; + let w = paragraph_words(&p, "en-US"); + let got: Vec<(&str, &str, usize)> = w.iter().map(|w| (w.text.as_str(), w.lang.as_str(), w.start)).collect(); + assert_eq!( + got, + vec![ + ("Hello", "en-GB", 0), + ("world", "en-GB", 6), + ("Grüße", "de-DE", 13), + ("well", "de-DE", 19), + ("known", "de-DE", 24), + ("l'été", "de-DE", 34) + ] + ); + } + #[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"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f052d955..0e3884ac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -871,3 +871,41 @@ fn spelling_without_a_dictionary_falls_back_to_common_misspellings() { assert!(d["found"].is_null()); assert_eq!(d["available"], false); } + +#[test] +fn spelling_checks_each_run_in_its_language_and_learns_words() { + let dir = std::env::temp_dir().join(format!("deckcraft-userdic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + crate::spell::set_user_dictionary_path(dir.join("user.dic")); + crate::spell::install("xx-ONE", crate::spell::Dictionary::parse(b"", b"2\nhello\nworld\n")); + crate::spell::install("xx-TWO", crate::spell::Dictionary::parse(b"", b"2\nhallo\nwelt\n")); + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "hello hallo deckcraft"})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + // The second word is in another language (as a PPTX run's `lang` gives it). + s.edit(|doc, _| { + let sh = deckcraft_model::find_shape_mut(&mut std::sync::Arc::make_mut(&mut doc.slides[0]).shapes, id).unwrap(); + let p = &mut sh.text.as_mut().unwrap().paragraphs[0]; + let t = p.runs[0].text.clone(); + let props = p.runs[0].props.clone(); + p.runs = vec![ + deckcraft_model::Run::with(&t[..6], deckcraft_model::RunProps { lang: Some("xx-ONE".into()), ..props.clone() }), + deckcraft_model::Run::with(&t[6..12], deckcraft_model::RunProps { lang: Some("xx-TWO".into()), ..props.clone() }), + deckcraft_model::Run::with(&t[12..], deckcraft_model::RunProps { lang: Some("xx-ONE".into()), ..props }), + ]; + Ok(()) + }) + .unwrap(); + let v = s.execute("review.spelling", &json!({})).unwrap(); + let words: Vec<_> = v + .as_array() + .unwrap() + .iter() + .map(|w| (w["word"].as_str().unwrap().to_string(), w["lang"].as_str().unwrap().to_string(), w["start"].as_u64().unwrap())) + .collect(); + assert_eq!(words, vec![("deckcraft".to_string(), "xx-ONE".to_string(), 12)]); + s.execute("review.addToDictionary", &json!({"word": "deckcraft"})).unwrap(); + assert!(s.execute("review.spelling", &json!({})).unwrap().as_array().unwrap().is_empty()); + assert_eq!(std::fs::read_to_string(dir.join("user.dic")).unwrap(), "deckcraft\n"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..3cd56b93 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -167,6 +167,12 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ime_output(app, ui, xf); media_bar(app, ui, xf); gif_buttons(app, ui, xf, &gifs); + if resp.secondary_clicked() + && let Some(p) = resp.interact_pointer_pos() + { + let word = app.session.active().and_then(|st| app.squiggles.word_at(st, xf.to_slide(p), 4.0 / xf.scale.max(0.01) as f64)); + app.squiggles.menu_word = word; + } context_menu(app, &resp); } @@ -430,6 +436,14 @@ fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) { } fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) { + // Spelling squiggles under misspelt words (checked in the background). + if let Some(st) = app.session.active() { + let lines = app.squiggles.update(st, Some(ui.ctx())); + let scale = (xf.to_screen(Point::new(1.0, 0.0)) - xf.to_screen(Point::new(0.0, 0.0))).length(); + for (a, b) in lines { + crate::squiggles::paint(painter, xf.to_screen(a), xf.to_screen(b), scale); + } + } let Some(st) = app.session.active() else { return }; let sel_stroke = Stroke::new(1.0, t.selection); let editing = st.selection.text.clone(); @@ -622,6 +636,32 @@ fn draw_preview(app: &SlideApp, p: &egui::Painter, xf: Xf, x: Xfrm, t: &Tokens) fn context_menu(app: &mut SlideApp, resp: &egui::Response) { resp.context_menu(|ui| { + // A misspelt word under the pointer: its suggestions, then Ignore All and Add to Dictionary. + if let Some(w) = app.squiggles.menu_word.clone() { + if w.suggestions.is_empty() { + ui.label(egui::RichText::new("(no spelling suggestions)").weak()); + } + for to in &w.suggestions { + if ui.button(egui::RichText::new(to).strong()).clicked() { + let _ = app.run("review.spellingChange", json!({"shape": w.shape, "para": w.para, "start": w.start, "word": w.word, "to": to})); + app.squiggles.menu_word = None; + ui.close(); + } + } + if ui.button("Ignore All").clicked() { + app.squiggles.ignore(&w.word); + app.squiggles.menu_word = None; + ui.close(); + } + if ui.button("Add to Dictionary").clicked() { + if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { + app.set_status(e.to_string()); + } + app.squiggles.menu_word = None; + ui.close(); + } + ui.separator(); + } let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); for (l, id) in [("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste")] { if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cca5dc5d..67f2826f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -556,6 +556,11 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if ui.small_button("Ignore All").clicked() { add_ignore = Some(w.to_string()); } + if ui.small_button("Add to Dictionary").clicked() + && let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w})) + { + app.set_status(e.to_string()); + } }); let sug: Vec = item .get("suggestions") diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..db572078 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -20,6 +20,7 @@ pub mod ribbon; pub mod selpane; pub mod show; pub mod sorter; +pub mod squiggles; pub mod status; pub mod textures; pub mod theme; @@ -183,6 +184,8 @@ pub struct SlideApp { pub ui: UiState, pub textures: textures::Textures, pub control_rx: Option>, + /// Spelling squiggles, checked on a worker thread. + pub squiggles: squiggles::Squiggles, pub dialog: Option, pub show: Option, /// Audio/video playback (editor control bar, slide show, `media.*` commands). @@ -238,6 +241,7 @@ impl SlideApp { ui: UiState::default(), textures: textures::Textures::default(), control_rx: None, + squiggles: Default::default(), dialog: None, show: None, media: media::MediaHost::new(audio_out), diff --git a/crates/ui-egui/src/spelling_tests.rs b/crates/ui-egui/src/spelling_tests.rs index 67e3ee64..7d9b5537 100644 --- a/crates/ui-egui/src/spelling_tests.rs +++ b/crates/ui-egui/src/spelling_tests.rs @@ -44,3 +44,72 @@ fn spelling_dialog_changes_and_ignores_words() { h.run_steps(3); assert!(h.query_by_label_contains("No issues found").is_some()); } + +/// Misspelt words on the slide get squiggles from the background check; not the word being +/// typed; the context menu's correction and Add to Dictionary clear them. +#[test] +fn squiggles_mark_misspelt_words_in_the_background() { + let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nSFX S Y 1\nSFX S 0 s .\n"; + let dic = "7\nthe/S\nslide/S\nquarter/S\nplan/S\nour\nfor\nreview\n"; + deckcraft_engine::spell::install("xx-SQ", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes())); + let dir = std::env::temp_dir().join(format!("deckcraft-ui-userdic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + deckcraft_engine::spell::set_user_dictionary_path(dir.join("user.dic")); + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("slide.layout", &json!({"layout": "blank"})).ok(); + let id = app + .session + .execute("shape.insert", &json!({"preset": "rect", "rect": [80, 120, 700, 160], "text": "Our plna for teh quarter reveiw"})) + .unwrap()["id"] + .as_u64() + .unwrap(); + app.session.execute("format.size", &json!({"size": 40})).unwrap(); + // Mark the runs' language directly (what a PPTX run's `lang` gives). + let sid = deckcraft_model::ShapeId(id as u32); + app.session + .edit(|doc, _| { + let sl = std::sync::Arc::make_mut(&mut doc.slides[0]); + let sh = deckcraft_model::find_shape_mut(&mut sl.shapes, sid).unwrap(); + for r in sh.text.as_mut().unwrap().paragraphs.iter_mut().flat_map(|p| p.runs.iter_mut()) { + r.props.lang = Some("xx-SQ".into()); + } + Ok(()) + }) + .unwrap(); + app.session.execute("edit.select", &json!({"ids": []})).unwrap(); + let st = app.session.active().unwrap().clone(); + let lines = app.squiggles.settle(&st); + assert_eq!(lines.len(), 3, "{lines:?}"); + 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(4); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_SQUIGGLES"), h.render()) { + let _ = img.save(path); + } + // The context menu's word under the first squiggle, and its correction. + let st = h.state().session.active().unwrap().clone(); + let (a, _) = lines[0]; + let w = h.state().squiggles.word_at(&st, deckcraft_geom::Point::new(a.x + 5.0, a.y - 5.0), 2.0).unwrap(); + assert_eq!(w.word, "plna"); + assert_eq!(w.suggestions.first().map(String::as_str), Some("plan")); + h.state_mut() + .session + .execute("review.spellingChange", &json!({"shape": id, "para": w.para, "start": w.start, "word": w.word, "to": "plan"})) + .unwrap(); + h.state_mut().session.execute("review.addToDictionary", &json!({"word": "reveiw"})).unwrap(); + let st = h.state().session.active().unwrap().clone(); + assert_eq!(h.state_mut().squiggles.settle(&st).len(), 1); + // Typing at the end of a misspelt word: no squiggle under it until the caret leaves. + h.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + h.state_mut().session.execute("text.edit", &json!({})).unwrap(); + h.state_mut().session.execute("text.select", &json!({"anchor": [0, 16], "caret": [0, 16]})).unwrap(); + let st = h.state().session.active().unwrap().clone(); + assert_eq!(st.selection.text.as_ref().map(|t| t.caret), Some((0, 16))); + assert_eq!(h.state_mut().squiggles.settle(&st).len(), 0, "the word being typed isn't marked"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/squiggles.rs b/crates/ui-egui/src/squiggles.rs new file mode 100644 index 00000000..17e55fdf --- /dev/null +++ b/crates/ui-egui/src/squiggles.rs @@ -0,0 +1,260 @@ +//! Spelling squiggles: misspelt words on the current slide get a red wavy underline, checked on a +//! worker thread so typing never waits for a dictionary. Results are for one version of the +//! document; while a newer check runs, nothing stale is drawn. The word being typed (the caret at +//! or inside it) isn't marked, as in PowerPoint. The web build has no worker and shows none. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::mpsc::{Receiver, Sender, channel}; + +use deckcraft_engine::DocState; +use deckcraft_geom::Point; +use deckcraft_model::{Paragraph, ShapeId}; + +/// What a check is for: the document version, the slide and the user dictionary version. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Key { + doc: usize, + rev: u64, + slide: usize, + user: u64, +} + +struct Job { + key: Key, + default_lang: String, + paras: Vec<(ShapeId, usize, Paragraph)>, + repaint: Option, +} + +/// Misspelt ranges for one key: `(shape, para, start, end)`. +struct Done { + key: Key, + ranges: Vec<(ShapeId, usize, usize, usize)>, +} + +#[derive(Default)] +pub struct Squiggles { + tx: Option>, + rx: Option>, + /// The check in flight, if any. + pending: Option, + done: Option, + /// Underlines in slide coordinates for `done`, with each one's word: `(shape, para, start, end)`. + lines: Vec<((ShapeId, usize, usize, usize), Point, Point)>, + /// Off (tests or the setting). + pub disabled: bool, + /// The misspelt word a context menu is open on. + pub menu_word: Option, + /// Words skipped with Ignore All this session (lower-case). + ignored: Vec, +} + +/// A misspelt word under the pointer, with corrections. +#[derive(Clone, Debug)] +pub struct MenuWord { + pub shape: ShapeId, + pub para: usize, + pub start: usize, + pub word: String, + pub suggestions: Vec, +} + +fn worker(rx: Receiver, tx: Sender) { + // Paragraph results by content, so typing in one paragraph re-checks only that one. + let mut cache: HashMap<(String, u64), Vec<(usize, usize)>> = HashMap::new(); + let mut user = None; + while let Ok(mut job) = rx.recv() { + // Only the newest job matters. + while let Ok(j) = rx.try_recv() { + job = j; + } + if user != Some(job.key.user) { + cache.clear(); + user = Some(job.key.user); + } + let mut ranges = vec![]; + for (id, pi, p) in &job.paras { + let k = (serde_json::to_string(&p.runs).unwrap_or_default(), job.key.user); + let k = (format!("{}\u{0}{}", job.default_lang, k.0), k.1); + let r = cache + .entry(k) + .or_insert_with(|| deckcraft_engine::spell::misspelt(p, &job.default_lang, &[]).into_iter().map(|w| (w.start, w.end)).collect()); + ranges.extend(r.iter().map(|(a, b)| (*id, *pi, *a, *b))); + } + if cache.len() > 20_000 { + cache.clear(); + } + if tx.send(Done { key: job.key, ranges }).is_err() { + return; + } + if let Some(c) = &job.repaint { + c.request_repaint(); + } + } +} + +fn default_lang(st: &DocState) -> String { + for sl in &st.doc.slides { + let mut found = None; + 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()); + } + }); + if let Some(l) = found { + return l; + } + } + "en-US".into() +} + +impl Squiggles { + fn start(&mut self) -> bool { + if self.tx.is_some() { + return true; + } + if cfg!(target_arch = "wasm32") { + return false; + } + let (jtx, jrx) = channel(); + let (dtx, drx) = channel(); + let ok = std::thread::Builder::new().name("spelling".into()).spawn(move || worker(jrx, dtx)).is_ok(); + if ok { + self.tx = Some(jtx); + self.rx = Some(drx); + } + ok + } + + /// Ask for a check of the current slide if it changed, take in finished checks, and return the + /// underlines to draw (slide coordinates). + pub fn update(&mut self, st: &DocState, repaint: Option<&egui::Context>) -> Vec<(Point, Point)> { + if self.disabled || !self.start() { + return vec![]; + } + let key = + Key { doc: Arc::as_ptr(&st.doc) as usize, rev: st.revision, slide: st.selection.slide, user: deckcraft_engine::spell::user_generation() }; + if let Some(rx) = &self.rx { + while let Ok(d) = rx.try_recv() { + if self.pending == Some(d.key) { + self.pending = None; + } + self.lines.clear(); + self.done = Some(d); + if let Some(d) = &self.done + && d.key == key + { + let mut by_shape: HashMap> = HashMap::new(); + for (id, p, a, b) in &d.ranges { + by_shape.entry(*id).or_default().push((*p, *a, *b)); + } + for (id, rs) in by_shape { + for r in rs { + for (a, b) in deckcraft_engine::cmd::text::range_underlines(st, id, &[r]) { + self.lines.push(((id, r.0, r.1, r.2), a, b)); + } + } + } + } + } + } + let fresh = self.done.as_ref().is_some_and(|d| d.key == key); + if !fresh && self.pending != Some(key) { + let mut paras = vec![]; + if let Some(sl) = st.doc.slides.get(st.selection.slide) { + deckcraft_model::walk(&sl.shapes, &mut |sh, _| { + if let Some(t) = &sh.text { + for (i, p) in t.paragraphs.iter().enumerate() { + paras.push((sh.id, i, p.clone())); + } + } + }); + } + let job = Job { key, default_lang: default_lang(st), paras, repaint: repaint.cloned() }; + if self.tx.as_ref().is_some_and(|tx| tx.send(job).is_ok()) { + self.pending = Some(key); + } + } + if !fresh { + return vec![]; + } + // Not under the word the caret is in. + let typing = st.selection.text.as_ref().filter(|t| !t.notes && t.cell.is_none()).map(|t| (t.shape, t.caret)); + self.lines + .iter() + .filter(|((id, p, a, b), _, _)| !typing.is_some_and(|(s, (cp, cc))| s == *id && cp == *p && cc >= *a && cc <= *b)) + .filter(|((id, p, a, b), _, _)| { + self.ignored.is_empty() || { + let w = st + .shape(*id) + .and_then(|sh| sh.text.as_ref()?.paragraphs.get(*p).map(|pp| pp.text().chars().skip(*a).take(b - a).collect::())); + !w.is_some_and(|w| self.ignored.contains(&w.to_lowercase())) + } + }) + .map(|(_, a, b)| (*a, *b)) + .collect() + } + + /// Skip `word` everywhere for the rest of the session. + pub fn ignore(&mut self, word: &str) { + self.ignored.push(word.to_lowercase()); + } + + /// The squiggled word at slide point `p` (within `tol` points of its underline). + pub fn word_at(&self, st: &DocState, p: Point, tol: f64) -> Option { + let ((shape, para, start, end), _, _) = *self.lines.iter().find(|(_, a, b)| { + let (x0, x1) = (a.x.min(b.x), a.x.max(b.x)); + // From the line's top (a word is about a line high above its underline) to just below it. + p.x >= x0 - tol && p.x <= x1 + tol && p.y <= a.y.max(b.y) + tol && p.y >= a.y.min(b.y) - 24.0 + })?; + let pp = st + .doc + .slides + .get(st.selection.slide) + .and_then(|sl| deckcraft_model::find_shape(&sl.shapes, shape))? + .text + .as_ref()? + .paragraphs + .get(para)?; + let word: String = pp.text().chars().skip(start).take(end - start).collect(); + let lang = deckcraft_engine::spell::paragraph_words(pp, &default_lang(st)).into_iter().find(|w| w.start == start).map(|w| w.lang)?; + let suggestions = deckcraft_engine::spell::dictionary(&lang).map(|d| d.suggest(&word, 5)).unwrap_or_default(); + Some(MenuWord { shape, para, start, word, suggestions }) + } + + /// Wait (tests) until the check for the current state is in. + pub fn settle(&mut self, st: &DocState) -> Vec<(Point, Point)> { + for _ in 0..500 { + let v = self.update(st, None); + if self.done.as_ref().is_some_and(|d| self.pending.is_none() && d.key.doc == Arc::as_ptr(&st.doc) as usize) { + return v; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + vec![] + } +} + +/// A red wavy line from `a` to `b` (screen points). +pub fn paint(painter: &egui::Painter, a: egui::Pos2, b: egui::Pos2, scale: f32) { + let d = b - a; + let len = d.length(); + if len < 1.0 { + return; + } + let (u, n) = (d / len, egui::vec2(-d.y, d.x) / len); + let step = (2.0 * scale).clamp(1.5, 3.0); + let amp = step * 0.75; + let mut pts = vec![]; + let mut t = 0.0; + let mut up = false; + while t <= len { + pts.push(a + u * t + n * if up { -amp } else { amp }); + up = !up; + t += step; + } + painter.add(egui::Shape::line(pts, egui::Stroke::new(1.0, egui::Color32::from_rgb(0xE0, 0x20, 0x20)))); +} diff --git a/docs/gaps.md b/docs/gaps.md index f965a5d6..3cc546cd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -176,12 +176,14 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * ### Proofing: spelling, thesaurus, AutoCorrect - **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. + (`crates/engine/src/spell.rs`: affixes, cross products, one level of continuation, compounds, + case rules, suggestions), each run in its own language (`lang` from PPTX), with a user dictionary + for Add to Dictionary. Red squiggles on the current slide come from a worker thread, with a + context menu to correct, ignore or add. None ship with DeckCraft, so without one installed (always + on Windows and the web) only a short list of common misspellings is found, and the web build has + no squiggles. Notes and table cells aren't squiggled. Thesaurus (#549) and AutoCorrect (#521) have open PRs. -- **Estimate:** 4–8 h (bundle openly licensed dictionaries, squiggles, custom dictionary, compounding). +- **Estimate:** 2–4 h (bundle openly licensed dictionaries; squiggles in notes and table cells). ### Equations - **Evidence:** checklist row missing; PR #55 (OMML editor, +10.5k lines) is open. @@ -260,6 +262,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Spelling squiggles while typing, Add to Dictionary, per-run languages and compounds | | 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 |