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())]);
}