M10.2/M11: spell checking (Moby PD word list + user dictionary, suggestions); Step and Repeat (count or grid)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 12:22:14 -04:00
co-authored by Claude Opus 5.5
parent 7cd23dd284
commit d8c7d4455d
7 changed files with 246 additions and 0 deletions
+1
View File
@@ -137,6 +137,7 @@ impl Document {
assets: BTreeMap::new(),
hyperlinks: vec![],
bookmarks: vec![],
user_words: vec![],
next_id: 0,
};
let lid = LayerId(d.alloc());
+3
View File
@@ -232,6 +232,9 @@ pub struct Document {
/// PDF bookmarks (Window → Interactive → Bookmarks).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bookmarks: Vec<Bookmark>,
/// Words added to the document's user dictionary (spelling).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub user_words: Vec<String>,
pub next_id: u64,
}
+2
View File
@@ -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());
+37
View File
@@ -245,6 +245,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
ok()
})
}
fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> {
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<Vec2> = 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()}))
})
}
+190
View File
@@ -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<CommandSpec> {
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<String> {
let chars: Vec<char> = w.chars().collect();
let mut out: Vec<String> = Vec::new();
let mut push = |c: Vec<char>| {
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<Value> {
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<StoryId> = 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);
}
}
+12
View File
@@ -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<String> {
use hayro_interpret::font::Glyph;
+1
View File
@@ -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)