Review: spelling with Hunspell dictionaries, suggestions, Change and Ignore All

This commit is contained in:
Nikita Rakitin
2026-10-14 01:52:53 +03:00
parent 457406a7a5
commit ec1f25e617
7 changed files with 910 additions and 46 deletions
+184 -36
View File
@@ -16,7 +16,17 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
Ok(Value::Array(out))
}
fn spelling(s: &mut Session, _p: &Value) -> Result<Value> {
// 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<Value> {
let st = s.doc()?;
let lang = check_lang(&st.doc, p);
let ignore: Vec<String> =
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<char> = 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<Value> {
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<Value> {
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<Value> {
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::<Vec<_>>()}))
}
fn start(s: &mut Session, from: usize) -> Result<Value> {
s.ui_requests.push(UiRequest::StartShow { from });
Ok(json!({"from": from}))
+1
View File
@@ -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;
+566
View File
@@ -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<char>, 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<Cond> {
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<Cond>,
cont: Vec<Flag>,
}
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<String, Vec<Vec<Flag>>>,
pfx: Vec<Affix>,
sfx: Vec<Affix>,
try_chars: Vec<char>,
rep: Vec<(String, String)>,
word_chars: Vec<char>,
forbidden: Option<Flag>,
need_affix: Option<Flag>,
no_suggest: Option<Flag>,
keep_case: Option<Flag>,
}
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<Flag> {
match mode {
FlagMode::Char => s.chars().map(|c| c as u32).collect(),
FlagMode::Long => {
let c: Vec<char> = 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<Flag> {
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::<usize>().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::<usize>().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::<usize>().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::<usize>().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<Flag>) -> 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::<String>(), &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<char> = 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::<String>(), &[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<String> {
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<char> = lower.chars().collect();
let mut out: Vec<String> = vec![];
let mut seen = HashSet::new();
let mut push = |s: String, out: &mut Vec<String>| {
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<char> = if self.try_chars.is_empty() { "esianrtolcdugmphbyfvkwzxjq".chars().collect() } else { self.try_chars.clone() };
let s = |v: &[char]| v.iter().collect::<String>();
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<Vec<char>> {
let add: Vec<char> = 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<Vec<char>> {
let add: Vec<char> = add.chars().collect();
if w.len() <= add.len() && strip.is_empty() || !w.starts_with(&add) {
return None;
}
let mut v: Vec<char> = 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<PathBuf> {
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<String> = 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<HashMap<String, Option<Arc<Dictionary>>>>;
fn cache() -> &'static Cache {
static CACHE: OnceLock<Cache> = 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<Arc<Dictionary>> {
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);
}
}
+36
View File
@@ -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);
}
+69 -7
View File
@@ -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<String> = 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;
+46
View File
@@ -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());
}
+8 -3
View File
@@ -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 |