Part of #4936: check spelling per paragraph, given ids in paint order; dictionary sources

- text.spelling.check gives craft-proof one paragraph at a time, as
  check_text_with expects: its language detection reads a paragraph, so a
  Vietnamese paragraph no longer leaves the English ones after it unchecked,
  and the check stops at the paragraph where the 10,000-word list fills up
  instead of proofing the rest of the text first.
- Given `ids` are checked in paint order, as the result promises, not in the
  order they were passed.
- Docs: English words come from the first dictionary craft-proof finds
  (dictionaries folder Hunspell, SCOWL from craft-i18n, system Hunspell),
  with the Moby word list as the last fallback, not from Moby alone.
- Doc comments on the command functions and test helpers.
This commit is contained in:
HassanKhan
2026-11-08 21:53:42 +05:00
parent d45a538476
commit fc2a27d119
5 changed files with 63 additions and 16 deletions
+41 -12
View File
@@ -8,7 +8,7 @@ use std::ops::Range;
use craft_proof::{IssueKind, Options, ProofLang};
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_doc::{Document, NodeId, NodeKind};
use super::menucmds::whole_or;
use super::typemenu::{all_texts, text_ids};
@@ -23,6 +23,7 @@ pub const MAX_SUGGESTIONS: u64 = 20;
/// Longest word suggestions are looked for, in bytes: the search costs more with the length.
const MAX_WORD_LEN: usize = 128;
/// The Edit › Spelling commands.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -30,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Check Spelling",
[],
None,
"{ids?: [id…] (type objects, or groups holding them; default: every visible, unlocked type object)} the words the dictionaries don't know, each in its run's language (English when it has none; none in a language without a dictionary), in paint order → {words: [{id, start: byte, end: byte, word, lang}…], more: bool (more than 10000 words, the rest left out)}. text.editRange {id, start, end, insert} changes one",
"{ids?: [id…] (type objects, or groups holding them; default: every visible, unlocked type object)} the words the dictionaries don't know, each in its run's language (English when it has none; none in a language without a dictionary), each paragraph on its own, in paint order (given ids too) → {words: [{id, start: byte, end: byte, word, lang}…], more: bool (more than 10000 words, the rest left out)}. text.editRange {id, start, end, insert} changes one",
has_doc,
check
),
@@ -46,12 +47,14 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// `text.spelling.check`: the unknown words of the given type objects (or every visible, unlocked
/// one), in paint order, each paragraph checked on its own, at most [`MAX_WORDS`] of them.
fn check(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.spelling.check";
// Given `ids` are checked like Find and Replace's, so a malformed one is an error rather than
// a check of the whole document.
let ids = match p.get("ids").filter(|v| !v.is_null()) {
Some(_) => text_ids(&s.doc()?.doc, &checked_ids_param(s, p, "ids", C)?),
Some(_) => in_paint_order(&s.doc()?.doc, text_ids(&s.doc()?.doc, &checked_ids_param(s, p, "ids", C)?)),
None => all_texts(&s.doc()?.doc),
};
let st = s.doc()?;
@@ -69,21 +72,47 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
spans.push((at..end, ProofLang::from_tag(r.style.language.as_deref())));
at = end;
}
for issue in craft_proof::check_text_with(&text, &spans, &opts, false, false) {
if issue.kind != IssueKind::Spelling {
continue;
// craft-proof checks one paragraph at a time (its language detection reads the
// paragraph), and the check stops at the paragraph where the list fills up rather than
// proofing the rest of the text.
let mut para_start = 0;
for para in text.split('\n') {
let para_end = para_start + para.len();
let para_spans: Vec<(Range<usize>, ProofLang)> = spans
.iter()
.filter_map(|(r, lang)| {
let (a, b) = (r.start.max(para_start), r.end.min(para_end));
(a < b).then(|| (a - para_start..b - para_start, *lang))
})
.collect();
for issue in craft_proof::check_text_with(para, &para_spans, &opts, false, false) {
if issue.kind != IssueKind::Spelling {
continue;
}
let (start, end) = (para_start + issue.start, para_start + issue.end);
let Some(word) = text.get(start..end) else { continue };
if words.len() == MAX_WORDS {
more = true;
break 'texts;
}
words.push(json!({ "id": id.0, "start": start, "end": end, "word": word, "lang": issue.lang.tag() }));
}
let Some(word) = text.get(issue.start..issue.end) else { continue };
if words.len() == MAX_WORDS {
more = true;
break 'texts;
}
words.push(json!({ "id": id.0, "start": issue.start, "end": issue.end, "word": word, "lang": issue.lang.tag() }));
// Past the paragraph and its `\n`.
para_start = para_end + 1;
}
}
Ok(json!({ "words": words, "more": more }))
}
/// `ids` sorted back to front, as the document paints them: by their index paths, which are in
/// paint order.
fn in_paint_order(d: &Document, mut ids: Vec<NodeId>) -> Vec<NodeId> {
let paths = d.index_paths(ids.iter().copied());
ids.sort_by(|a, b| paths.get(a).cmp(&paths.get(b)));
ids
}
/// `text.spelling.suggest`: up to `max` of the words the dictionary of `lang` suggests for `word`.
fn suggest(_s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.spelling.suggest";
let word = str_param(p, "word").map(str::trim).filter(|w| !w.is_empty()).ok_or_else(|| bad(C, "missing `word`"))?;
+16
View File
@@ -5,16 +5,19 @@ use serde_json::{Value, json};
use crate::*;
/// A session with a new 400 × 400 document.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
/// Runs a command that must succeed and gives its result.
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap()
}
/// New point type at (10, `y`) holding `t`.
fn text(s: &mut Session, y: f64, t: &str) -> NodeId {
NodeId(run(s, "text.create", json!({"x": 10, "y": y, "text": t}))["id"].as_u64().unwrap())
}
@@ -60,6 +63,8 @@ fn check_goes_through_visible_unlocked_type_in_paint_order_or_the_ids_given() {
assert_eq!(ids, [a.0, b.0], "hidden and locked type are left out");
assert_eq!(unknown(&mut s, json!({"ids": [b.0]})), [(b.0, 7, 12, "wvxzq".to_string())]);
assert_eq!(unknown(&mut s, json!({"ids": null})).len(), 2, "null ids is the default");
let both = [(a.0, 6, 11, "qzxvw".to_string()), (b.0, 7, 12, "wvxzq".to_string())];
assert_eq!(unknown(&mut s, json!({"ids": [b.0, a.0]})), both, "given ids are listed in paint order too");
for ids in [json!(b.0), json!("all"), json!({"id": b.0}), json!([-1]), json!(["x"]), json!([999_999])] {
assert!(s.execute("text.spelling.check", &json!({"ids": ids})).is_err(), "malformed ids {ids} is refused");
}
@@ -87,3 +92,14 @@ fn suggest_offers_dictionary_words() {
assert!(s.execute("text.spelling.suggest", &json!({"word": "drawng", "max": max})).is_err(), "max {max}");
}
}
#[test]
fn each_paragraph_is_checked_on_its_own_at_the_bytes_of_the_whole_text() {
let mut s = session();
// Read as one block, the Vietnamese first paragraph would make the whole text Vietnamese,
// which has no dictionary, and the English typo below it would go unmarked.
let vi = "Cảm ơn những người bạn";
let t = text(&mut s, 50.0, &format!("{vi}\ndrawing qzxvw tools"));
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())]);
}
+4 -2
View File
@@ -5468,8 +5468,10 @@ returns the new value.
`text.spelling.check {ids?}` lists the words of the document's type that its dictionaries don't know: every
visible, unlocked type object in paint order, or those in `ids` (and in the groups `ids` names). Each run is checked
in its Character panel Language (English when it has none) by craft-proof; a language without a spelling dictionary
marks nothing. Words in capitals, words with digits and web addresses are taken as correct. The result is
in its Character panel Language (English when it has none) by craft-proof, one paragraph at a time; a language without
a spelling dictionary marks nothing. English words come from the first dictionary craft-proof finds: a Hunspell
dictionary in the user's dictionaries folder, the SCOWL dictionary for that English built in from craft-i18n, a
system Hunspell dictionary, and last the public-domain Moby word list built into craft-proof. Words in capitals, words with digits and web addresses are taken as correct. The result is
`{words: [{id, start, end, word, lang}], more}`: `start` and `end` are byte offsets in the type object's text, the
ones `text.editRange` takes, so `text.editRange {id, start, end, insert}` changes a word as one undo step. At most
10,000 words are listed; `more` is `true` when there were more. `text.spelling.suggest {word, max?, lang?}` gives
+1 -1
View File
@@ -1771,7 +1771,7 @@ A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool ente
## Spell check engine (2026-11-08)
Edit › Spelling has the commands its Check Spelling dialog will use, as the public documentation describes checking the document's text, suggesting replacements and changing a word ([Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html)). `text.spelling.check` lists the words the dictionaries don't know in every visible, unlocked type object, or in the ones given by `ids`, in paint order, each run checked in its Character panel Language (English when it has none; nothing in a language without a dictionary). Each word comes with its object and byte range, which `text.editRange` changes as one undo step. `text.spelling.suggest` gives one word's suggestions. Both come from craft-proof in [storytold/craft-libs](https://github.com/storytold/craft-libs), with English from the public-domain Moby word list (#4936; `tests/spelling.rs`). The Check Spelling dialog, Auto Spell Check and Edit Custom Dictionary remain, so the Edit › Spelling menu items are still placeholders.
Edit › Spelling has the commands its Check Spelling dialog will use, as the public documentation describes checking the document's text, suggesting replacements and changing a word ([Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html)). `text.spelling.check` lists the words the dictionaries don't know in every visible, unlocked type object, or in the ones given by `ids`, in paint order, each run checked in its Character panel Language (English when it has none; nothing in a language without a dictionary). Each word comes with its object and byte range, which `text.editRange` changes as one undo step. `text.spelling.suggest` gives one word's suggestions. Both come from craft-proof in [storytold/craft-libs](https://github.com/storytold/craft-libs), which checks each paragraph on its own. English words come from the first dictionary craft-proof finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary for that English built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into craft-proof (#4936; `tests/spelling.rs`). The Check Spelling dialog, Auto Spell Check and Edit Custom Dictionary remain, so the Edit › Spelling menu items are still placeholders.
## Control bar: Paragraph section while type is selected (2026-11-06)
+1 -1
View File
@@ -47,7 +47,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` |
| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together |
| Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` |
| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof (English from the public-domain Moby word list built into it; other languages from installed Hunspell dictionaries). The Check Spelling dialog, Auto Spell Check and Edit Custom Dictionary remain (the Edit › Spelling items are still off). Evidence: `crates/engine/src/tests/spelling.rs` |
| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The Check Spelling dialog, Auto Spell Check and Edit Custom Dictionary remain (the Edit › Spelling items are still off). Evidence: `crates/engine/src/tests/spelling.rs` |
| Smart Punctuation, Change Case, Find & Replace | done | |
| Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut (`windows_alt_codes` tests; not yet tried on a Windows machine). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character |
| Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner |