M10.1: Find/Change (text + GREP with InDesign metacharacters, formatting on change) + dialog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fe54a974c5
commit
fbc07c0fbd
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ serde = { workspace = true }
|
|||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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<regex::Regex> {
|
||||
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<Vec<(StoryId, std::ops::Range<usize>)>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<usize>, 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<Value> {
|
||||
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<StoryId> = 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Value, String> {
|
|||
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}")),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Result<Value,
|
|||
Ok(Value::Null)
|
||||
}
|
||||
"app.exportPng" => 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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue