Part of #4936: custom dictionary and Ignore All commands

text.spelling.addWord, removeWord and changeWord are Check Spelling's Add
and Edit Custom Dictionary's Add, Delete and Change; text.spelling.dictionary
lists the words. They live in craft-proof, which every language's check
reads. The desktop app and vectorcraft-cli keep them in
`Custom Dictionary.txt` next to the preferences (none without preferences);
elsewhere they last until the app closes.

text.spelling.ignoreAll is Ignore All: the active document's checks skip the
word while it is open; not saved, not undoable, and not a change to the
document's revision.

Behaviour: https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html
This commit is contained in:
HassanKhan
2026-11-09 13:54:08 +05:00
parent 1844904504
commit 5bef0ed79a
13 changed files with 287 additions and 19 deletions
+133 -2
View File
@@ -2,7 +2,10 @@
//! (`text.spelling.check`) and the words they suggest instead (`text.spelling.suggest`), checked
//! by craft-proof in each run's language
//! ([Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html)).
//! A word is changed with `text.editRange` over the bytes the check gives.
//! A word is changed with `text.editRange` over the bytes the check gives. Add and Edit Custom
//! Dictionary keep words in the custom dictionary (`text.spelling.dictionary`, `addWord`,
//! `removeWord`, `changeWord`), which every document's check takes as right; Ignore All
//! (`text.spelling.ignoreAll`) skips a word in the active document only.
use std::ops::Range;
@@ -12,7 +15,7 @@ use vectorcraft_doc::{Document, NodeId, NodeKind};
use super::menucmds::whole_or;
use super::typemenu::{all_texts, text_ids};
use super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, has_doc, str_param};
use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, has_doc, str_param};
/// Most unknown words one check lists; past it the result says `more`.
pub const MAX_WORDS: usize = 10_000;
@@ -44,6 +47,51 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
suggest
),
cmd!(
query "text.spelling.dictionary",
"Edit Custom Dictionary",
[],
None,
"{} the words of the custom dictionary, sorted (lower case, as they are kept) → {words: [word…]}",
always,
dictionary
),
cmd!(
query "text.spelling.addWord",
"Add to Custom Dictionary",
[],
None,
"{word} adds word (one word, at most 64 characters; kept in lower case) to the custom dictionary, which every language's check takes as right, and saves it → {word, added: bool (false: it was there already)}",
always,
add_word
),
cmd!(
query "text.spelling.removeWord",
"Delete from Custom Dictionary",
[],
None,
"{word} deletes word from the custom dictionary and saves it (an error when it isn't there) → {word}",
always,
remove_word
),
cmd!(
query "text.spelling.changeWord",
"Change in Custom Dictionary",
[],
None,
"{word, to} replaces word in the custom dictionary with to and saves it (an error when word isn't there) → {word: to}",
always,
change_word
),
cmd!(
query "text.spelling.ignoreAll",
"Ignore All",
[],
None,
"{word} the active document's spelling checks skip word from now on, until it closes (not saved, not undoable) → {word}",
has_doc,
ignore_all
),
]
}
@@ -58,6 +106,7 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
None => all_texts(&s.doc()?.doc),
};
let st = s.doc()?;
let ignored = &st.spelling_ignored;
let opts = Options::default();
let mut words = vec![];
let mut more = false;
@@ -91,6 +140,9 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
}
let (start, end) = (para_start + issue.start, para_start + issue.end);
let Some(word) = text.get(start..end) else { continue };
if craft_proof::custom_word(word).is_some_and(|w| ignored.contains(&w)) {
continue;
}
if words.len() == MAX_WORDS {
more = true;
break 'texts;
@@ -128,3 +180,82 @@ fn suggest(_s: &mut Session, p: &Value) -> Result<Value> {
let suggestions = craft_proof::suggest_in(word, usize::try_from(max).unwrap_or(5), lang);
Ok(json!({ "suggestions": suggestions }))
}
/// `p`'s `key` as a custom-dictionary word (trimmed, lower case), or an error naming what it must be.
fn word_param(p: &Value, key: &str, command: &str) -> Result<String> {
let w = str_param(p, key).ok_or_else(|| bad(command, format!("missing `{key}`")))?;
craft_proof::custom_word(w)
.ok_or_else(|| bad(command, format!("`{key}` must be one word of at most {} characters", craft_proof::MAX_CUSTOM_WORD_CHARS)))
}
/// Write the custom dictionary to its file when the session has one, leaving out `removed`.
fn save_dictionary(s: &Session, removed: &[String], command: &str) -> Result<()> {
let Some(path) = &s.dictionary_file else { return Ok(()) };
craft_proof::save_user_dictionary(path, removed)
.map_err(|e| bad(command, format!("the custom dictionary couldn't be saved to {}: {e}", path.display())))
}
/// `text.spelling.dictionary`: the custom dictionary's words.
fn dictionary(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({ "words": craft_proof::user_dictionary() }))
}
/// `text.spelling.addWord`: Add, and Edit Custom Dictionary's Add.
fn add_word(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.spelling.addWord";
let word = word_param(p, "word", C)?;
let added = !craft_proof::user_dictionary().contains(&word);
craft_proof::add_word(&word);
save_dictionary(s, &[], C)?;
Ok(json!({ "word": word, "added": added }))
}
/// `text.spelling.removeWord`: Edit Custom Dictionary's Delete.
fn remove_word(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.spelling.removeWord";
let word = word_param(p, "word", C)?;
if !craft_proof::remove_word(&word) {
return Err(bad(C, format!("`{word}` isn't in the custom dictionary")));
}
save_dictionary(s, std::slice::from_ref(&word), C)?;
Ok(json!({ "word": word }))
}
/// `text.spelling.changeWord`: Edit Custom Dictionary's Change.
fn change_word(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.spelling.changeWord";
let word = word_param(p, "word", C)?;
let to = word_param(p, "to", C)?;
if !craft_proof::user_dictionary().contains(&word) {
return Err(bad(C, format!("`{word}` isn't in the custom dictionary")));
}
let removed = if word == to {
vec![]
} else {
craft_proof::remove_word(&word);
craft_proof::add_word(&to);
vec![word]
};
save_dictionary(s, &removed, C)?;
Ok(json!({ "word": to }))
}
/// `text.spelling.ignoreAll`: Ignore All, for the active document.
fn ignore_all(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.spelling.ignoreAll";
let word = word_param(p, "word", C)?;
// Not a change to the document, so its revision stays: a tool edit computed meanwhile still
// applies (`command_sweep`'s check of commands that don't wait for one).
s.doc_mut()?.spelling_ignored.insert(word.clone());
Ok(json!({ "word": word }))
}
impl Session {
/// Keep the custom dictionary in the file at `path` (the desktop app's, beside its
/// preferences), and take in the words already there. A missing or unreadable file reads as
/// no words; a hostile one is capped by craft-proof.
pub fn attach_custom_dictionary(&mut self, path: std::path::PathBuf) {
craft_proof::load_user_dictionary(&path);
self.dictionary_file = Some(path);
}
}
+4
View File
@@ -58,6 +58,9 @@ pub struct DocState {
/// View Opacity Mask (Alt-click the mask thumbnail): the masked object whose mask the canvas
/// shows alone, in greyscale, while that mask is edited (see [`DocState::shown_mask`]).
pub mask_view: Option<NodeId>,
/// Check Spelling's Ignore All: the words (as craft-proof keys them, lower case) a spelling
/// check of this document skips. Kept while the document is open: not saved, not undoable.
pub spelling_ignored: std::collections::BTreeSet<String>,
/// View → Show Transparency Grid, per document (saved view state, not undoable).
pub transparency_grid: bool,
/// The format Save writes ([`cmd::fileio::SAVE_FORMATS`]): the one the document was opened
@@ -288,6 +291,7 @@ impl DocState {
uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
opened_as_tab: true,
mask_view: None,
spelling_ignored: Default::default(),
transparency_grid,
format: "vectorcraft",
save_options: Default::default(),
+3
View File
@@ -148,6 +148,9 @@ pub struct Session {
/// Where the saved PDF presets are kept, one file each; `None`: only for the session
/// ([`Session::attach_pdf_preset_store`]).
pdf_preset_dir: Option<std::path::PathBuf>,
/// The custom dictionary file Edit › Spelling › Add and Edit Custom Dictionary save to; `None`:
/// the words are kept only for the process ([`Session::attach_custom_dictionary`]).
dictionary_file: Option<std::path::PathBuf>,
/// Where the ICC profiles `color.loadProfile` loads are kept; `None`: only for the process
/// ([`Session::attach_profile_store`]).
profile_dir: Option<std::path::PathBuf>,
+1
View File
@@ -80,6 +80,7 @@ impl Session {
brush_libraries: Default::default(),
libraries: Default::default(),
pdf_preset_dir: None,
dictionary_file: None,
profile_dir: None,
plugin_dir: None,
ignored_plugin_grants: Default::default(),
+98
View File
@@ -103,3 +103,101 @@ fn each_paragraph_is_checked_on_its_own_at_the_bytes_of_the_whole_text() {
let at = vi.len() + 1 + "drawing ".len();
assert_eq!(unknown(&mut s, json!({})), [(t.0, at as u64, at as u64 + 5, "qzxvw".to_string())]);
}
// The custom dictionary is craft-proof's, shared by the whole process, so each test below uses
// made-up words of its own: tests running at the same time never see each other's.
/// The words a check of the whole active document lists.
fn marked(s: &mut Session) -> Vec<String> {
unknown(s, json!({})).into_iter().map(|w| w.3).collect()
}
#[test]
fn added_words_are_right_in_every_check_until_deleted() {
let mut s = session();
text(&mut s, 50.0, "drawing Zqvwkx tools");
assert_eq!(marked(&mut s), ["Zqvwkx"]);
// Kept in lower case, so the capitalized word is right too.
let r = run(&mut s, "text.spelling.addWord", json!({"word": " Zqvwkx "}));
assert_eq!((r["word"].as_str(), r["added"].as_bool()), (Some("zqvwkx"), Some(true)));
assert_eq!(marked(&mut s), Vec::<String>::new());
assert_eq!(run(&mut s, "text.spelling.addWord", json!({"word": "zqvwkx"}))["added"], false, "already there");
let words = run(&mut s, "text.spelling.dictionary", json!({}))["words"].clone();
assert!(words.as_array().unwrap().contains(&json!("zqvwkx")), "{words}");
// Another document's check takes it as right too.
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
text(&mut s, 50.0, "zqvwkx");
assert_eq!(marked(&mut s), Vec::<String>::new());
run(&mut s, "text.spelling.removeWord", json!({"word": "ZQVWKX"}));
assert_eq!(marked(&mut s), ["zqvwkx"]);
let e = s.execute("text.spelling.removeWord", &json!({"word": "zqvwkx"})).unwrap_err().to_string();
assert!(e.contains("isn't in the custom dictionary"), "{e}");
}
#[test]
fn a_changed_word_replaces_the_old_one() {
let mut s = session();
text(&mut s, 50.0, "xkvzqa xkvzqb");
run(&mut s, "text.spelling.addWord", json!({"word": "xkvzqa"}));
assert_eq!(marked(&mut s), ["xkvzqb"]);
assert_eq!(run(&mut s, "text.spelling.changeWord", json!({"word": "xkvzqa", "to": "Xkvzqb"}))["word"], "xkvzqb");
assert_eq!(marked(&mut s), ["xkvzqa"]);
let e = s.execute("text.spelling.changeWord", &json!({"word": "xkvzqa", "to": "x"})).unwrap_err().to_string();
assert!(e.contains("isn't in the custom dictionary"), "{e}");
run(&mut s, "text.spelling.removeWord", json!({"word": "xkvzqb"}));
}
#[test]
fn the_custom_dictionary_file_is_read_and_kept_up_to_date() {
let dir = vectorcraft_testkit::temp_dir("spelling-dictionary");
let file = dir.join("Custom Dictionary.txt");
std::fs::write(&file, "kqzvwx\nnot one word\n\n").unwrap();
let mut s = session();
s.attach_custom_dictionary(file.clone());
text(&mut s, 50.0, "kqzvwx vkqzwx");
assert_eq!(marked(&mut s), ["vkqzwx"], "the file's word is right; its line of three words is skipped");
run(&mut s, "text.spelling.addWord", json!({"word": "vkqzwx"}));
let saved = std::fs::read_to_string(&file).unwrap();
assert!(saved.lines().any(|l| l == "kqzvwx") && saved.lines().any(|l| l == "vkqzwx"), "{saved}");
run(&mut s, "text.spelling.changeWord", json!({"word": "kqzvwx", "to": "kqzvwy"}));
run(&mut s, "text.spelling.removeWord", json!({"word": "vkqzwx"}));
let saved = std::fs::read_to_string(&file).unwrap();
assert!(saved.lines().any(|l| l == "kqzvwy"), "{saved}");
assert!(!saved.lines().any(|l| l == "kqzvwx" || l == "vkqzwx"), "changed and deleted words are gone from the file: {saved}");
run(&mut s, "text.spelling.removeWord", json!({"word": "kqzvwy"}));
}
#[test]
fn ignore_all_skips_a_word_in_the_active_document_only() {
let mut s = session();
text(&mut s, 50.0, "Wqzxkv drawing wqzxkv vqzxkw");
run(&mut s, "text.spelling.ignoreAll", json!({"word": "WQZXKV"}));
assert_eq!(marked(&mut s), ["vqzxkw"], "every occurrence, whatever its case");
let words = run(&mut s, "text.spelling.dictionary", json!({}))["words"].clone();
assert!(!words.as_array().unwrap().contains(&json!("wqzxkv")), "not added to the custom dictionary");
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
text(&mut s, 50.0, "wqzxkv");
assert_eq!(marked(&mut s), ["wqzxkv"], "another document still marks it");
}
#[test]
fn custom_dictionary_commands_refuse_what_isnt_one_word() {
let mut s = session();
let long = "q".repeat(65);
for (id, p) in [
("text.spelling.addWord", json!({})),
("text.spelling.addWord", json!({"word": " "})),
("text.spelling.addWord", json!({"word": "two words"})),
("text.spelling.addWord", json!({"word": long})),
("text.spelling.addWord", json!({"word": 5})),
("text.spelling.removeWord", json!({})),
("text.spelling.changeWord", json!({"word": "qzx"})),
("text.spelling.changeWord", json!({"word": "qzx", "to": "a b"})),
("text.spelling.ignoreAll", json!({"word": "a\tb"})),
] {
assert!(s.execute(id, &p).is_err(), "{id} {p} is refused");
}
let mut empty = Session::new();
assert!(empty.execute("text.spelling.ignoreAll", &json!({"word": "qzx"})).is_err(), "Ignore All needs a document");
assert!(empty.execute("text.spelling.dictionary", &json!({})).is_ok(), "the dictionary is the app's");
}