diff --git a/Cargo.lock b/Cargo.lock index d373ce9..5c4e368 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,6 +134,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -810,6 +819,7 @@ dependencies = [ "designcraft-render", "designcraft-tools", "log", + "regex", "serde", "serde_json", "thiserror 2.0.21", @@ -2779,6 +2789,29 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + [[package]] name = "regex-syntax" version = "0.8.11" diff --git a/Cargo.toml b/Cargo.toml index 50220d7..92514ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" proptest = "1" rayon = "1" +regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } # text / rendering / formats skrifa = "0.47" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 5d9e5ec..9a54462 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -20,3 +20,4 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +regex = { workspace = true } diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs new file mode 100644 index 0000000..8f3170e --- /dev/null +++ b/crates/engine/src/cmd/find.rs @@ -0,0 +1,234 @@ +//! Edit → Find/Change: plain text and GREP (regular expressions) with InDesign's metacharacters, +//! optional formatting in the change, scoped to the document, a story or the selection. + +use designcraft_doc::{StoryId, TextSel, story}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param}; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None, + "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?} → matches [{story, start, end, text}]", + has_doc, find), + cmd!( + "find.change", + "Change All", + ["Edit", "Find/Change"], + None, + "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, first?: bool (change only the first match), attrs?: {character attributes applied to changed text}} → {count}", + has_doc, + change + ), + cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, …} — selects the next match after the caret", has_doc, find_next), + ] +} + +/// Translate InDesign text/GREP metacharacters to our story encoding. +fn metachars(s: &str, grep: bool) -> String { + let pairs: &[(&str, &str)] = if grep { + &[ + ("~b", "\n"), + ("\\r", "\n"), + ("~#", "\u{E000}"), + ("~x", "\u{E001}"), + ("~M", "\u{E002}"), + ("~R", "\u{E003}"), + ("~P", "\u{E004}"), + ("~=", "\u{2014}"), + ("~_", "\u{2014}"), + ("~-", "\u{AD}"), + ("~S", "\u{A0}"), + ("~n", "\u{2028}"), + ] + } else { + &[ + ("^p", "\n"), + ("^t", "\t"), + ("^n", "\u{2028}"), + ("^#", "\u{E000}"), + ("^_", "\u{2014}"), + ("^=", "\u{2013}"), + ("^-", "\u{AD}"), + ("^s", "\u{A0}"), + ("^^", "^"), + ] + }; + let mut out = s.to_string(); + for (a, b) in pairs { + out = out.replace(a, b); + } + out +} + +fn build(p: &Value) -> Result { + let f = str_param(p, "find").ok_or_else(|| bad("find", "missing `find`"))?; + if f.is_empty() { + return Err(bad("find", "empty search")); + } + let grep = bool_or(p, "grep", false); + let mut pat = if grep { metachars(f, true) } else { regex::escape(&metachars(f, false)) }; + if bool_or(p, "wholeWord", false) { + pat = format!(r"\b(?:{pat})\b"); + } + regex::RegexBuilder::new(&pat) + .case_insensitive(!bool_or(p, "caseSensitive", false)) + .multi_line(true) + .build() + .map_err(|e| bad("find", format!("bad GREP: {e}"))) +} + +fn scope(s: &Session, p: &Value) -> Result)>> { + let st = s.doc()?; + let d = &st.doc; + if let Some(sid) = p.get("story").and_then(Value::as_u64) { + let sid = StoryId(sid); + let len = d.story(sid).map(|x| x.len()).ok_or_else(|| bad("find", "no such story"))?; + return Ok(vec![(sid, 0..len)]); + } + match str_param(p, "scope").unwrap_or("document") { + "story" => { + let t = st.selection.text.ok_or_else(|| bad("find", "no story selected"))?; + Ok(vec![(t.story, 0..d.story(t.story).map(|x| x.len()).unwrap_or(0))]) + } + "selection" => { + let t = st.selection.text.ok_or_else(|| bad("find", "no text selected"))?; + Ok(vec![(t.story, t.range())]) + } + _ => Ok(d.stories.values().map(|x| (x.id, 0..x.len())).collect()), + } +} + +fn find(s: &mut Session, p: &Value) -> Result { + let re = build(p)?; + let st = s.doc()?; + let mut out = Vec::new(); + for (sid, r) in scope(s, p)? { + let Some(story) = st.doc.story(sid) else { continue }; + let text = &story.text[r.clone()]; + for m in re.find_iter(text) { + out.push(json!({"story": sid.0, "start": r.start + m.start(), "end": r.start + m.end(), "text": m.as_str()})); + if out.len() >= 10_000 { + break; + } + } + } + Ok(Value::Array(out)) +} + +/// Expand `$1`… (GREP) or text metacharacters in the change string. +fn replacement(p: &Value) -> String { + let c = str_param(p, "change").unwrap_or(""); + metachars(c, bool_or(p, "grep", false)) +} + +fn change(s: &mut Session, p: &Value) -> Result { + let re = build(p)?; + let grep = bool_or(p, "grep", false); + let rep = replacement(p); + let first_only = bool_or(p, "first", false); + let attrs = p.get("attrs").cloned(); + let mut over = designcraft_doc::CharAttrs::default(); + if let Some(o) = attrs.as_ref().and_then(Value::as_object) { + for (k, v) in o { + over.set_json(k, v).map_err(|e| bad("find.change", e))?; + } + } + let targets = scope(s, p)?; + s.edit(|d, sel| { + let mut count = 0usize; + for (sid, r) in targets { + let Some(story) = d.story(sid) else { continue }; + // Collect matches first (positions refer to the original text), apply back to front. + let text = story.text[r.clone()].to_string(); + let mut edits: Vec<(std::ops::Range, String)> = Vec::new(); + for caps in re.captures_iter(&text) { + let m = caps.get(0).expect("match"); + let mut out = String::new(); + if grep { + caps.expand(&rep, &mut out); + } else { + out = rep.clone(); + } + edits.push((r.start + m.start()..r.start + m.end(), out)); + if first_only { + break; + } + } + let st = d.story_mut(sid).expect("story"); + for (range, new) in edits.iter().rev() { + st.replace(range.clone(), new); + if !over.is_empty() && !new.is_empty() { + st.format_chars(range.start..range.start + new.len(), |f| f.over.merge(&over)); + } + count += 1; + } + if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) { + let len = st.len(); + t.anchor = t.anchor.min(len); + t.focus = t.focus.min(len); + } + } + Ok(json!({"count": count})) + }) +} + +fn find_next(s: &mut Session, p: &Value) -> Result { + let re = build(p)?; + let st = s.doc()?; + let (start_story, start_pos) = st.selection.text.map(|t| (Some(t.story), t.range().end)).unwrap_or((None, 0)); + let mut order: Vec = st.doc.stories.keys().copied().collect(); + if let Some(ss) = start_story + && let Some(i) = order.iter().position(|x| *x == ss) + { + order.rotate_left(i); + } + let mut hit = None; + for (k, sid) in order.iter().enumerate() { + let Some(story) = st.doc.story(*sid) else { continue }; + let from = if k == 0 && start_story == Some(*sid) { start_pos.min(story.len()) } else { 0 }; + if let Some(m) = re.find_at(&story.text, from) { + hit = Some((*sid, m.start(), m.end())); + break; + } + } + let Some((sid, a, b)) = hit else { return Ok(Value::Null) }; + let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied()); + let stm = s.doc_mut()?; + stm.selection = designcraft_doc::Selection::text(TextSel { story: sid, anchor: a, focus: b, frame }); + stm.revision += 1; + let _ = story::PAGE_NUMBER; + Ok(json!({"story": sid.0, "start": a, "end": b})) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn find_and_change_text_and_grep() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Call 555-1234 or 555-9876.\nColour and colour."})) + .unwrap(); + let m = s.execute("find.find", &json!({"find": "colour"})).unwrap(); + assert_eq!(m.as_array().unwrap().len(), 2); + let m = s.execute("find.find", &json!({"find": "colour", "caseSensitive": true})).unwrap(); + assert_eq!(m.as_array().unwrap().len(), 1); + let r = s.execute("find.change", &json!({"find": r"(\d{3})-(\d{4})", "change": "($1) $2", "grep": true})).unwrap(); + assert_eq!(r["count"], 2); + let r = s.execute("find.change", &json!({"find": "colour", "change": "color", "attrs": {"fontStyle": "Italic"}})).unwrap(); + assert_eq!(r["count"], 2); + let d = s.execute("document.inspect", &json!({})).unwrap(); + let sid = d["stories"][0]["id"].as_u64().unwrap(); + let text = s.execute("story.get", &json!({"story": sid})).unwrap()["text"].as_str().unwrap().to_string(); + assert_eq!(text, "Call (555) 1234 or (555) 9876.\ncolor and color."); + // Paragraph metacharacter in text mode. + assert_eq!(s.execute("find.find", &json!({"find": ".^pc"})).unwrap().as_array().unwrap().len(), 1); + s.execute("find.next", &json!({"find": "or"})).unwrap(); + assert!(s.doc().unwrap().selection.text.is_some()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a95f288..4f58ff0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,6 +2,7 @@ mod edit; mod file; +mod find; mod inspect; mod layout; mod object; @@ -106,6 +107,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(style::specs()); v.extend(layout::specs()); v.extend(inspect::specs()); + v.extend(find::specs()); v }) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b8f6abf..0af3e86 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -26,6 +26,7 @@ impl Dialog { "marginTop": "3p0", "marginBottom": "3p0", "marginInside": "3p0", "marginOutside": "3p0", "bleed": "0p0", "primaryTextFrame": false}) } "goToPage" => json!({"page": 1}), + "findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}), "textFrameOptions" => json!({"columns": 1, "gutter": "1p0", "inset": "0p0", "verticalJustification": "top"}), "documentSetup" => json!({}), _ => json!({}), @@ -93,6 +94,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "goToPage" => "Go to Page", "textFrameOptions" => "Text Frame Options", "documentSetup" => "Document Setup", + "findChange" => "Find/Change", _ => "Dialog", }; egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| { @@ -158,6 +160,67 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.end_row(); }); } + "findChange" => { + ui.horizontal(|ui| { + for (label, grep) in [("Text", false), ("GREP", true)] { + if ui.selectable_label(d.b("grep") == grep, label).clicked() { + d.fields.insert("grep".into(), json!(grep)); + } + } + }); + egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + ui.label("Find what:"); + text_field(ui, &mut d, "find", 260.0); + ui.end_row(); + ui.label("Change to:"); + text_field(ui, &mut d, "change", 260.0); + ui.end_row(); + ui.label("Search:"); + let cur = d.s("scope"); + egui::ComboBox::from_id_salt("fcscope").selected_text(&cur).show_ui(ui, |ui| { + for v in ["document", "story", "selection"] { + if ui.selectable_label(cur == v, v).clicked() { + d.fields.insert("scope".into(), json!(v)); + } + } + }); + ui.end_row(); + }); + ui.horizontal(|ui| { + check(ui, &mut d, "caseSensitive", "Case sensitive"); + check(ui, &mut d, "wholeWord", "Whole word"); + }); + ui.horizontal(|ui| { + let params0 = json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")}); + let params = || params0.clone(); + if ui.button("Find Next").clicked() { + let r = app.run("find.next", params()); + let msg = match r { + Ok(Value::Null) => "No matches".to_string(), + Ok(_) => "Found".to_string(), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + if ui.button("Change All").clicked() { + let msg = match app.run("find.change", params()) { + Ok(v) => format!("{} replacement(s) made", v["count"]), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + if ui.button("Count").clicked() { + let msg = match app.run("find.find", params()) { + Ok(v) => format!("{} match(es)", v.as_array().map(|a| a.len()).unwrap_or(0)), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + }); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } + } "goToPage" => { ui.horizontal(|ui| { ui.label("Page"); @@ -264,6 +327,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { json!({"columns": d.n("columns").unwrap_or(1.0) as u64, "gutter": d.m("gutter").unwrap_or(12.0), "inset": d.m("inset").unwrap_or(0.0), "verticalJustification": d.s("verticalJustification")}), ), "documentSetup" => app.run("layout.documentSetup", json!({"width": d.m("width"), "height": d.m("height")})), + "findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})), other => Err(format!("unknown dialog {other}")), } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e78678a..e7b00cb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -13,6 +13,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.save", "Save", Some("Cmd+S"), "{}"), ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), ("app.palette", "Command Palette…", Some("Cmd+K"), "{}"), + ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), ("view.fitPage", "Fit Page in Window", Some("Cmd+0"), "{}"), @@ -197,6 +198,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option export_png(app, p), + "app.findChange" => { + app.ui.dialog = Some(crate::dialogs::Dialog::new("findChange", json!({}))); + Ok(Value::Null) + } "app.palette" => { app.ui.palette = Some(String::new()); Ok(Value::Null)