diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index d20305b6e..582dd2848 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -137,6 +137,7 @@ impl Document { assets: BTreeMap::new(), hyperlinks: vec![], bookmarks: vec![], + user_words: vec![], next_id: 0, }; let lid = LayerId(d.alloc()); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 18fa788fe..8d91fc002 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -232,6 +232,9 @@ pub struct Document { /// PDF bookmarks (Window → Interactive → Bookmarks). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub bookmarks: Vec, + /// Words added to the document's user dictionary (spelling). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub user_words: Vec, pub next_id: u64, } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9b397e65b..08d1e36b2 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -11,6 +11,7 @@ pub mod interchange; mod layout; mod object; pub mod preflight; +pub mod spelling; mod style; pub mod text; @@ -115,6 +116,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(layout::specs()); v.extend(inspect::specs()); v.extend(find::specs()); + v.extend(spelling::specs()); v.extend(datamerge::specs()); v.extend(interactive::specs()); v.extend(preflight::specs()); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index daf1c45cb..889258256 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -245,6 +245,15 @@ pub fn specs() -> Vec { let name = str_param(p, "name").unwrap_or("").to_string(); set_flag(s, p, move |i| i.name = name.clone(), false) }), + cmd!( + "edit.stepAndRepeat", + "Step and Repeat…", + ["Edit"], + Some("Cmd+Alt+U"), + "{count?: 1, dx?, dy?, rows?, columns?} — copies of the selection offset by (dx, dy); with rows/columns, a grid", + has_selection, + step_and_repeat + ), cmd!("path.create", "Create Path", [], None, "{spread?, anchors: [{p:[x,y], in?:[x,y], out?:[x,y]}], closed?: bool}", has_doc, path_create), cmd!("path.appendAnchor", "Add Anchor", [], None, "{id, anchor: {p, in?, out?}} (spread coords)", has_doc, path_append), cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id}", has_doc, path_close), @@ -983,3 +992,31 @@ fn path_move_anchors(s: &mut Session, p: &Value) -> Result { ok() }) } + +fn step_and_repeat(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let (dx, dy) = (f64_or(p, "dx", 12.0), f64_or(p, "dy", 12.0)); + let rows = p.get("rows").and_then(Value::as_u64).map(|v| v as usize); + let cols = p.get("columns").and_then(Value::as_u64).map(|v| v as usize); + let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize; + let offsets: Vec = match (rows, cols) { + (Some(r), Some(c)) if r * c <= 1000 => (0..r) + .flat_map(|i| (0..c).map(move |j| (i, j))) + .filter(|&(i, j)| i + j > 0) + .map(|(i, j)| Vec2::new(j as f64 * dx, i as f64 * dy)) + .collect(), + _ => (1..=count).map(|k| Vec2::new(k as f64 * dx, k as f64 * dy)).collect(), + }; + s.edit(|d, sel| { + let src = d.clone(); + let mut all = ids.clone(); + for off in offsets { + for id in &ids { + let sr = src.find(*id).map(|l| l.spread).unwrap_or(SpreadRef::Doc(0)); + all.extend(duplicate_from(d, &src, &[*id], sr, off)?); + } + } + *sel = Selection::items(all.clone()); + Ok(json!({"created": all.len() - ids.len()})) + }) +} diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs new file mode 100644 index 000000000..ab66dceb6 --- /dev/null +++ b/crates/engine/src/cmd/spelling.rs @@ -0,0 +1,190 @@ +//! Edit → Spelling: check spelling against the bundled US English word list (the public-domain +//! Moby list used for hyphenation) plus the document's user dictionary; suggestions by edit distance. + +use designcraft_compose::hyphen::Dictionary; +use designcraft_doc::StoryId; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!(query "spelling.check", "Check Spelling…", ["Edit", "Spelling"], Some("Cmd+I"), + "{story?, suggestions?: true} → [{story, start, end, word, suggestions}]", has_doc, check), + cmd!("spelling.addWord", "Add to Dictionary", ["Edit", "Spelling"], None, "{word}", has_doc, |s, p| { + let w = str_param(p, "word").ok_or_else(|| bad("spelling.addWord", "missing word"))?.to_lowercase(); + s.edit(|d, _| { + if !d.user_words.contains(&w) { + d.user_words.push(w.clone()); + } + Ok(Value::Null) + }) + }), + cmd!("spelling.change", "Change", [], None, "{story, start, end, to}", has_doc, |s, p| { + let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("spelling.change", "missing story"))?); + let (a, b) = (p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize, p.get("end").and_then(Value::as_u64).unwrap_or(0) as usize); + let to = str_param(p, "to").unwrap_or("").to_string(); + s.edit(|d, _| { + let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; + st.replace(a.min(st.len())..b.min(st.len()), &to); + Ok(Value::Null) + }) + }), + ] +} + +/// Is `w` (lowercase) a known word, allowing common inflections of dictionary stems? +pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool { + if w.len() <= 1 || dict.get(w).is_some() || user.iter().any(|u| u == w) { + return true; + } + let w = w.trim_end_matches("'s").trim_end_matches("’s"); + if dict.get(w).is_some() { + return true; + } + let stems: [(&str, &[&str]); 9] = [ + ("ies", &["y"]), + ("es", &["", "e"]), + ("s", &[""]), + ("ed", &["", "e"]), + ("ing", &["", "e"]), + ("ly", &[""]), + ("er", &["", "e"]), + ("est", &["", "e"]), + ("ness", &[""]), + ]; + for (suf, adds) in stems { + if let Some(base) = w.strip_suffix(suf) { + for add in adds { + let cand = format!("{base}{add}"); + if cand.len() > 1 && dict.get(&cand).is_some() { + return true; + } + // Doubled consonant: "stopped" → "stop". + if let Some(c) = base.chars().last() + && base.len() > 2 + && base.ends_with(&format!("{c}{c}")) + && dict.get(&base[..base.len() - c.len_utf8()]).is_some() + { + return true; + } + } + } + } + false +} + +/// Up to 6 suggestions at edit distance 1, ranked: transpositions, replacements, deletions, insertions. +pub fn suggest(dict: &Dictionary, w: &str) -> Vec { + let chars: Vec = w.chars().collect(); + let mut out: Vec = Vec::new(); + let mut push = |c: Vec| { + let c: String = c.into_iter().collect(); + if c != w && !out.contains(&c) && dict.get(&c).is_some() { + out.push(c); + } + }; + for i in 0..chars.len().saturating_sub(1) { + let mut c = chars.clone(); + c.swap(i, i + 1); + push(c); + } + for i in 0..chars.len() { + for l in 'a'..='z' { + let mut c = chars.clone(); + c[i] = l; + push(c); + } + } + for i in 0..chars.len() { + let mut c = chars.clone(); + c.remove(i); + push(c); + } + for i in 0..=chars.len() { + for l in 'a'..='z' { + let mut c = chars.clone(); + c.insert(i, l); + push(c); + } + } + out.truncate(6); + out +} + +fn check(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let d = &st.doc; + let dict = Dictionary::en_us(); + let want_sugg = p.get("suggestions").and_then(Value::as_bool).unwrap_or(true); + let stories: Vec = match p.get("story").and_then(Value::as_u64) { + Some(id) => vec![StoryId(id)], + None => d.stories.keys().copied().collect(), + }; + let mut out = Vec::new(); + for sid in stories { + let Some(story) = d.story(sid) else { continue }; + let text = &story.text; + let mut i = 0; + let bytes: Vec<(usize, char)> = text.char_indices().collect(); + while i < bytes.len() { + if !bytes[i].1.is_alphabetic() { + i += 1; + continue; + } + let start = bytes[i].0; + let mut j = i; + while j < bytes.len() + && (bytes[j].1.is_alphabetic() + || ((bytes[j].1 == '\'' || bytes[j].1 == '’') && j + 1 < bytes.len() && bytes[j + 1].1.is_alphabetic())) + { + j += 1; + } + let end = bytes.get(j).map(|b| b.0).unwrap_or(text.len()); + let word = &text[start..end]; + let lower = word.to_lowercase(); + // Skip words with digits around them and all-caps acronyms. + let acronym = word.chars().all(|c| c.is_uppercase()) && word.chars().count() <= 5; + if !acronym && !known(dict, &d.user_words, &lower) { + let sugg = if want_sugg { suggest(dict, &lower) } else { vec![] }; + out.push(json!({"story": sid.0, "start": start, "end": end, "word": word, "suggestions": sugg})); + } + i = j.max(i + 1); + } + } + Ok(Value::Array(out)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_words_and_suggestions() { + let d = Dictionary::en_us(); + if d.is_empty() { + return; // data not bundled in this build + } + for w in ["typography", "pages", "composed", "arranging", "quickly", "layouts", "stopped"] { + assert!(known(d, &[], w), "{w}"); + } + assert!(!known(d, &[], "typograpy")); + assert!(suggest(d, "typograpy").contains(&"typography".to_string())); + assert!(known(d, &["designcraft".into()], "designcraft")); + } + + #[test] + fn check_finds_misspellings() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Thw quick brown fox jumpd over the NASA dog."})) + .unwrap(); + let r = s.execute("spelling.check", &json!({})).unwrap(); + let words: Vec<&str> = r.as_array().unwrap().iter().map(|m| m["word"].as_str().unwrap()).collect(); + assert_eq!(words, vec!["Thw", "jumpd"]); + assert!(r[0]["suggestions"].as_array().unwrap().iter().any(|v| v == "the")); + s.execute("spelling.addWord", &json!({"word": "jumpd"})).unwrap(); + assert_eq!(s.execute("spelling.check", &json!({})).unwrap().as_array().unwrap().len(), 1); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 069f7610b..4b7ceaa91 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -241,6 +241,18 @@ fn hyperlinks_and_bookmarks_reach_the_pdf() { assert_eq!(back.bookmarks.len(), 2); } +#[test] +fn step_and_repeat_grid() { + let mut s = session(); + s.execute("frame.create", &json!({"rect": [36, 36, 66, 66], "content": "unassigned"})).unwrap(); + let r = s.execute("edit.stepAndRepeat", &json!({"rows": 3, "columns": 4, "dx": 40, "dy": 40})).unwrap(); + assert_eq!(r["created"], 11); + assert_eq!(s.doc().unwrap().doc.spreads[0].items.len(), 12); + let last = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds(); + assert_eq!(last.x0, 36.0 + 3.0 * 40.0); + assert_eq!(last.y0, 36.0 + 2.0 * 40.0); +} + /// Unicode text per page, through hayro's interpreter (ToUnicode / ActualText). fn pdf_text(bytes: &[u8]) -> Vec { use hayro_interpret::font::Glyph; diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 142844283..3fc536751 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1353,6 +1353,7 @@ impl<'r> Importer<'r> { assets: std::mem::take(&mut self.assets), hyperlinks: vec![], bookmarks: vec![], + user_words: vec![], next_id: self.next_id, }; Ok(d)