Files
designcraft/crates/engine/src/cmd/find.rs
T
aiongg 8f1c60e4fc Merge upstream main 835edae6
Brings #514 (MCP request replay), #523 final (already in next), #403
(character fill must name a swatch), #520 and #521 finals (already in
next as their PR heads).

Conflict: crates/engine/src/cmd/object.rs: next's side throughout (transform.move doc with content?, object.fill with tint alone from #525, move_content, reorder/content_move/group/tint_alone test modules); upstream's copy_ids_tests already in next
Conflict: crates/engine/src/cmd/text.rs: both; #403's validate_char_fill after next's ruby_cmd
Conflict: crates/pdf/src/export.rs: next's side (item/item_guarded cycle guard, frame_objects; upstream's anchored loop dropped as in d11ccad3)
Conflict: crates/render/src/lib.rs: next's side (drawing_items, draw_item/draw_item_inner)
2026-10-12 04:15:40 +00:00

338 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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_compose::grep::{Grep, GrepOptions, translate_replacement};
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!(noundo "find.objects", "Find Object", [], None,
"{fill?, stroke?: swatch, strokeWeight?, opacity? (0–1), kind?: text|graphic|shape|line|group, layer?, label?, select?: bool (default true)} → {ids} — Find/Change › Object, through the document",
has_doc, |s, p| {
let ids = find_objects(&s.doc()?.doc, p);
if p.get("select").and_then(Value::as_bool).unwrap_or(true) && !ids.is_empty() {
let st = s.doc_mut()?;
st.selection = designcraft_doc::Selection::items(ids.clone());
st.bump_revision();
}
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}),
cmd!(
"find.changeObjects",
"Change All (Object)",
[],
None,
"{…find.objects criteria, change: {fill?, stroke?, strokeWeight?, opacity?}} → {changed}",
has_doc,
|s, p| {
let ids = find_objects(&s.doc()?.doc, p);
let ch = p.get("change").cloned().unwrap_or(Value::Null);
if !ch.is_object() {
return Err(bad("find.changeObjects", "`change` required"));
}
s.edit(|d, _| {
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
if let Some(f) = ch.get("fill").and_then(Value::as_str) {
it.fill = designcraft_doc::Fill::swatch(f);
}
if let Some(c) = ch.get("stroke").and_then(Value::as_str) {
it.stroke.swatch = c.to_string();
}
if let Some(w) = ch.get("strokeWeight").and_then(Value::as_f64) {
it.stroke.weight = w.max(0.0);
}
if let Some(o) = ch.get("opacity").and_then(Value::as_f64) {
it.opacity = o.clamp(0.0, 1.0) as f32;
}
}
Ok(json!({"changed": ids.len()}))
})
}
),
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 text-search metacharacters (`^p`, `^t`…) to our story encoding.
fn metachars(s: &str) -> String {
let pairs: &[(&str, &str)] = &[
("^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
}
/// The search pattern: GREP (in the documents' dialect, see `designcraft_compose::grep`) or
/// literal text with metacharacters.
fn build(p: &Value) -> Result<Grep> {
let f = str_param(p, "find").ok_or_else(|| bad("find", "missing `find`"))?;
if f.is_empty() {
return Err(bad("find", "empty search"));
}
let opts = GrepOptions { case_insensitive: !bool_or(p, "caseSensitive", false), multi_line: true, whole_word: bool_or(p, "wholeWord", false) };
let re = if bool_or(p, "grep", false) { Grep::new(f, opts) } else { Grep::literal(&metachars(f), opts) };
re.map_err(|e| bad("find", 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()),
}
}
/// Most matches `find.find` returns.
const MAX_MATCHES: usize = 10_000;
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 Some(text) = story.text.get(r.clone()) else { continue };
for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? {
out.push(json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)}));
}
}
Ok(Value::Array(out))
}
/// The change string: an expansion template (`$1`…) for GREP, else literal text.
fn replacement(p: &Value) -> String {
let c = str_param(p, "change").unwrap_or("");
if bool_or(p, "grep", false) { translate_replacement(c) } else { metachars(c) }
}
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))?;
}
}
super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?;
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 Some(text) = story.text.get(r.clone()) else { continue };
let edits: Vec<(std::ops::Range<usize>, String)> = if grep {
re.replacements(text, &rep, first_only)
} else {
re.find_all(text, if first_only { 1 } else { usize::MAX }).map(|ms| ms.into_iter().map(|m| (m, rep.clone())).collect())
}
.map_err(|e| bad("find.change", e))?
.into_iter()
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
.collect();
let Some(st) = d.story_mut(sid) else { continue };
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).map_err(|e| bad("find.next", e))? {
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, cell: None });
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());
}
#[test]
fn grep_in_the_documents_dialect() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Москва and Paris, 1\u{2013}2 days.\nok"})).unwrap();
// `\u` uppercase, `\l` lowercase letter.
let m = s.execute("find.find", &json!({"find": r"\u\l+", "grep": true, "caseSensitive": true})).unwrap();
let words: Vec<&str> = m.as_array().unwrap().iter().map(|x| x["text"].as_str().unwrap()).collect();
assert_eq!(words, ["Москва", "Paris"]);
// `~=` en dash, `\h` space, `\r` paragraph end; the change uses groups, `~S` and `\r`.
let r = s.execute("find.change", &json!({"find": r"(\d)~=(\d)\h(\l+)\.\r", "change": r"$1~=$2~S$3!\r", "grep": true})).unwrap();
assert_eq!(r["count"], 1);
let sid = s.execute("document.inspect", &json!({})).unwrap()["stories"][0]["id"].as_u64().unwrap();
let text = s.execute("story.get", &json!({"story": sid})).unwrap()["text"].as_str().unwrap().to_string();
assert!(text.starts_with("Москва and Paris, 1\u{2013}2\u{A0}days!\nok"), "{text:?}");
// A pattern that doesn't compile is an error, not a silent no-op.
assert!(s.execute("find.find", &json!({"find": "[[:nope:]]", "grep": true})).is_err());
assert!(s.execute("find.change", &json!({"find": "(", "change": "", "grep": true})).is_err());
}
}
/// Objects matching Find/Change › Object criteria, in document order (nested ones included).
fn find_objects(d: &designcraft_doc::Document, p: &Value) -> Vec<designcraft_doc::ItemId> {
use designcraft_doc::{Content, Shape};
let fill = str_param(p, "fill");
let stroke = str_param(p, "stroke");
let weight = p.get("strokeWeight").and_then(Value::as_f64);
let opacity = p.get("opacity").and_then(Value::as_f64);
let kind = str_param(p, "kind");
let layer = str_param(p, "layer").and_then(|n| d.layers.iter().find(|l| l.name == n).map(|l| l.id));
if str_param(p, "layer").is_some() && layer.is_none() {
return vec![];
}
let label = str_param(p, "label");
let mut out = Vec::new();
for sp in d.spreads.iter().chain(d.parents.iter()) {
for top in &sp.items {
top.walk(&mut |it| {
let k = match (&it.content, it.shape) {
(_, Shape::Group) => "group",
(Content::Text(_), _) => "text",
(Content::Graphic(_), _) => "graphic",
(_, Shape::GraphicLine) => "line",
_ => "shape",
};
let ok = fill.is_none_or(|f| it.fill.swatch == f)
&& stroke.is_none_or(|c| it.stroke.swatch == c && it.stroke.weight > 0.0)
&& weight.is_none_or(|w| (it.stroke.weight - w).abs() < 1e-6)
&& opacity.is_none_or(|o| (it.opacity as f64 - o).abs() < 1e-3)
&& kind.is_none_or(|kk| kk == k)
&& layer.is_none_or(|l| it.layer == l)
&& label.is_none_or(|lb| it.label == lb);
if ok {
out.push(it.id);
}
});
}
}
out
}
#[cfg(test)]
mod object_find_tests {
use serde_json::json;
use crate::Session;
#[test]
fn find_and_change_objects_by_attributes() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50], "content": "unassigned"})).unwrap()["id"].clone();
let _b = s.execute("frame.create", &json!({"rect": [60, 10, 90, 50], "content": "unassigned"})).unwrap()["id"].clone();
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [a]})).unwrap();
s.execute("frame.create", &json!({"rect": [10, 60, 90, 90], "content": "text", "text": "t"})).unwrap();
let r = s.execute("find.objects", &json!({"fill": "[Black]"})).unwrap();
assert_eq!(r["ids"], json!([a]));
assert_eq!(s.doc().unwrap().selection.items.len(), 1, "found objects are selected");
assert_eq!(s.execute("find.objects", &json!({"kind": "text", "select": false})).unwrap()["ids"].as_array().unwrap().len(), 1);
let c = s.execute("find.changeObjects", &json!({"kind": "shape", "change": {"opacity": 0.5}})).unwrap();
assert_eq!(c["changed"], 2);
assert_eq!(s.execute("find.objects", &json!({"opacity": 0.5, "select": false})).unwrap()["ids"].as_array().unwrap().len(), 2);
}
}