mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 12:24:54 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user