mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 11:54:03 +00:00
Part of #4936: text.spelling.check lists unknown words, text.spelling.suggest suggests
The Edit > Spelling items have been placeholders with nothing behind them. This adds the
engine half of Check Spelling on craft-proof, the shared spelling engine in craft-libs (at the
revision the other craft-libs crates already pin), so no word list is copied here:
- text.spelling.check {ids?} lists the words the dictionaries don't know in every visible,
unlocked type object (or ids), each run checked in its Character panel Language (English
when it has none; nothing in a language without a dictionary), as {id, start, end, word,
lang}. start/end are byte offsets in the object's text, the ones text.editRange takes, so a
word is changed with text.editRange as one undo step. At most 10,000 words, with `more`.
- text.spelling.suggest {word, max?, lang?} gives the dictionary's suggestions, asked for one
word at a time as craft-proof recommends.
English comes from the public-domain Moby word list built into craft-proof. Its dependencies
(log, miniz_oxide, toml, unicode-normalization) are already in the lockfile. The Check
Spelling dialog, Auto Spell Check and Edit Custom Dictionary follow in later PRs.
This commit is contained in:
Generated
+12
@@ -1035,6 +1035,17 @@ dependencies = [
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-proof"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
"toml",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -6243,6 +6254,7 @@ dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
"craft-proof",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"hayro-jpeg2000",
|
||||
|
||||
@@ -16,6 +16,7 @@ craft-print = { git = "https://github.com/storytold/craft-libs", rev = "9be9a637
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["serde"] }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
|
||||
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] }
|
||||
|
||||
@@ -17,6 +17,7 @@ bundled-fonts = ["vectorcraft-text/bundled-fonts"]
|
||||
[dependencies]
|
||||
# Which paths stored in documents may be opened.
|
||||
craft-docpath = { workspace = true }
|
||||
craft-proof = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
|
||||
@@ -80,6 +80,7 @@ mod select;
|
||||
pub(crate) mod shaper;
|
||||
pub mod shortcutset;
|
||||
pub(crate) mod slices;
|
||||
pub mod spelling;
|
||||
mod stroke;
|
||||
mod style;
|
||||
pub mod stylelib;
|
||||
@@ -317,6 +318,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(expand::specs());
|
||||
v.extend(attributes::specs());
|
||||
v.extend(interactivity::specs());
|
||||
v.extend(spelling::specs());
|
||||
v.extend(newart::specs());
|
||||
v.extend(colortheme::specs());
|
||||
v.extend(fileio::pdf::specs());
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Edit → Spelling: the words of the document's type that its dictionaries don't know
|
||||
//! (`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.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use craft_proof::{IssueKind, Options, ProofLang};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
use super::menucmds::whole_or;
|
||||
use super::typemenu::{all_texts, text_ids};
|
||||
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, ids_param, str_param};
|
||||
|
||||
/// Most unknown words one check lists; past it the result says `more`.
|
||||
pub const MAX_WORDS: usize = 10_000;
|
||||
|
||||
/// Most suggestions one word gets.
|
||||
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;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "text.spelling.check",
|
||||
"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",
|
||||
has_doc,
|
||||
check
|
||||
),
|
||||
cmd!(
|
||||
query "text.spelling.suggest",
|
||||
"Spelling Suggestions",
|
||||
[],
|
||||
None,
|
||||
"{word, max?: 1..20 (default 5), lang?: language tag (default English)} the words the dictionary of lang suggests instead of word, best first → {suggestions: [word…]}",
|
||||
has_doc,
|
||||
suggest
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(v) => text_ids(&st.doc, &v),
|
||||
None => all_texts(&st.doc),
|
||||
};
|
||||
let opts = Options::default();
|
||||
let mut words = vec![];
|
||||
let mut more = false;
|
||||
'texts: for id in ids {
|
||||
let Some(NodeKind::Text(t)) = st.doc.node(id).map(|n| &n.kind) else { continue };
|
||||
let text = t.plain_text();
|
||||
// Each run in its own language, at its bytes of the plain text.
|
||||
let mut spans: Vec<(Range<usize>, ProofLang)> = Vec::with_capacity(t.runs.len());
|
||||
let mut at = 0;
|
||||
for r in &t.runs {
|
||||
let end = at + r.text.len();
|
||||
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;
|
||||
}
|
||||
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() }));
|
||||
}
|
||||
}
|
||||
Ok(json!({ "words": words, "more": more }))
|
||||
}
|
||||
|
||||
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`"))?;
|
||||
let max = whole_or(p, "max", 5, C)?;
|
||||
if !(1..=MAX_SUGGESTIONS).contains(&max) {
|
||||
return Err(bad(C, format!("`max` must be 1 to {MAX_SUGGESTIONS}")));
|
||||
}
|
||||
if word.len() > MAX_WORD_LEN {
|
||||
return Ok(json!({ "suggestions": [] }));
|
||||
}
|
||||
let lang = ProofLang::from_tag(str_param(p, "lang"));
|
||||
// `max` is at most MAX_SUGGESTIONS, so it fits.
|
||||
let suggestions = craft_proof::suggest_in(word, usize::try_from(max).unwrap_or(5), lang);
|
||||
Ok(json!({ "suggestions": suggestions }))
|
||||
}
|
||||
@@ -713,7 +713,9 @@ fn find_all_between(hay: &str, needle: &str, match_case: bool, whole_word: bool,
|
||||
|
||||
/// The text objects Find and Replace and Find Next reach: on visible, unlocked layers, and not
|
||||
/// inside a hidden or locked group, which hides or locks what it holds as a layer does (#5900).
|
||||
fn all_texts(d: &Document) -> Vec<NodeId> {
|
||||
/// The visible, unlocked type objects of visible, unlocked layers, off locked artboards, in paint
|
||||
/// order: the text Find and Replace and Check Spelling go through.
|
||||
pub(crate) fn all_texts(d: &Document) -> Vec<NodeId> {
|
||||
fn visit(n: &Node, locks: &LockedArtboards<'_>, v: &mut Vec<NodeId>) {
|
||||
if !n.visible || n.locked {
|
||||
return;
|
||||
|
||||
@@ -154,6 +154,7 @@ mod simplify_part;
|
||||
mod slices;
|
||||
mod smartguides;
|
||||
mod smooth_path;
|
||||
mod spelling;
|
||||
mod store;
|
||||
mod strokegeom;
|
||||
mod strokegradient;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Edit → Spelling (`text.spelling.check`, `text.spelling.suggest`): the unknown words of the
|
||||
//! document's type, at the bytes `text.editRange` changes, each in its run's language.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap()
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
/// The unknown words a check lists: (id, start, end, word).
|
||||
fn unknown(s: &mut Session, p: Value) -> Vec<(u64, u64, u64, String)> {
|
||||
let r = run(s, "text.spelling.check", p);
|
||||
assert_eq!(r["more"], false);
|
||||
r["words"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|w| (w["id"].as_u64().unwrap(), w["start"].as_u64().unwrap(), w["end"].as_u64().unwrap(), w["word"].as_str().unwrap().to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_lists_unknown_words_at_the_bytes_edit_range_changes() {
|
||||
let mut s = session();
|
||||
// "é" is two bytes, so the unknown word starts at byte 14, not character 13.
|
||||
let t = text(&mut s, 50.0, "café drawing qzxvw tools");
|
||||
assert_eq!(unknown(&mut s, json!({})), [(t.0, 14, 19, "qzxvw".to_string())]);
|
||||
let w = &run(&mut s, "text.spelling.check", json!({}))["words"][0];
|
||||
assert_eq!(w["lang"], "en", "text with no language is checked in English");
|
||||
// Change: the bytes the check gave, as one undo step.
|
||||
run(&mut s, "text.editRange", json!({"id": t.0, "start": 14, "end": 19, "insert": "vector"}));
|
||||
assert_eq!(unknown(&mut s, json!({})), []);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(unknown(&mut s, json!({})).len(), 1, "undo brings the word back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_goes_through_visible_unlocked_type_in_paint_order_or_the_ids_given() {
|
||||
let mut s = session();
|
||||
let a = text(&mut s, 50.0, "first qzxvw");
|
||||
let b = text(&mut s, 100.0, "second wvxzq");
|
||||
let hidden = text(&mut s, 150.0, "hidden qqzzx");
|
||||
let locked = text(&mut s, 200.0, "locked zzqqx");
|
||||
run(&mut s, "object.hide", json!({"ids": [hidden.0]}));
|
||||
run(&mut s, "object.lock", json!({"ids": [locked.0]}));
|
||||
let ids: Vec<u64> = unknown(&mut s, json!({})).iter().map(|w| w.0).collect();
|
||||
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())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_language_without_a_dictionary_marks_nothing() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, 50.0, "qzxvw");
|
||||
run(&mut s, "text.setFormat", json!({"ids": [t.0], "language": "German"}));
|
||||
assert_eq!(unknown(&mut s, json!({})), [], "German has no spelling dictionary in craft-proof");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggest_offers_dictionary_words() {
|
||||
let mut s = session();
|
||||
let r = run(&mut s, "text.spelling.suggest", json!({"word": "drawng"}));
|
||||
let got: Vec<&str> = r["suggestions"].as_array().unwrap().iter().map(|w| w.as_str().unwrap()).collect();
|
||||
assert!(got.len() <= 5, "five at most by default: {got:?}");
|
||||
assert!(got.contains(&"drawing"), "{got:?}");
|
||||
assert_eq!(run(&mut s, "text.spelling.suggest", json!({"word": "drawng", "max": 1}))["suggestions"].as_array().unwrap().len(), 1);
|
||||
assert!(s.execute("text.spelling.suggest", &json!({})).is_err(), "a word is required");
|
||||
assert!(s.execute("text.spelling.suggest", &json!({"word": " "})).is_err(), "a blank word is refused");
|
||||
for max in [json!(0), json!(21), json!(-1), json!("3")] {
|
||||
assert!(s.execute("text.spelling.suggest", &json!({"word": "drawng", "max": max})).is_err(), "max {max}");
|
||||
}
|
||||
}
|
||||
+18
@@ -5464,6 +5464,24 @@ returns the new value.
|
||||
```
|
||||
|
||||
|
||||
## Spelling
|
||||
|
||||
`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
|
||||
`{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
|
||||
the words the dictionary of `lang` (default English) suggests instead of `word`, best first, at most `max` (1 to 20,
|
||||
default 5). Both only read the document.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.spelling.check","params":{}}}
|
||||
{"name":"run_command","arguments":{"command":"text.spelling.suggest","params":{"word":"drawng","lang":"en"}}}
|
||||
```
|
||||
|
||||
|
||||
## Justification options
|
||||
|
||||
`text.setFormat` sets the Paragraph panel menu's Justification… options of the selected type (or `ids`, or the
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Type parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
|
||||
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
|
||||
@@ -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) | missing | menu stubs; needs an openly licensed dictionary |
|
||||
| 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` |
|
||||
| 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 |
|
||||
@@ -79,6 +79,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-08 | minor | Spell check engine (#4936): `text.spelling.check` lists unknown words per run language at `text.editRange` byte offsets and `text.spelling.suggest` gives suggestions, from craft-proof; the dialog, Auto Spell Check and the custom dictionary remain. Scores and estimates unchanged. |
|
||||
| 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. |
|
||||
| 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged |
|
||||
| 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged |
|
||||
|
||||
Reference in New Issue
Block a user