//! Edit → Find/Change: plain text and GREP (regular expressions) with InDesign's metacharacters, //! a Find Format and a Change Format, scoped to the document, a story or the selection. use std::ops::Range; use std::sync::{Arc, Weak}; use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement}; use designcraft_doc::story::floor_char_boundary; use designcraft_doc::{CellAddr, Document, Selection, Story, StoryId, Styles, TextSel}; use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param}; use crate::{DocState, Result, Session}; pub fn specs() -> Vec { 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| { s.reset_find_scope(); 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::>()})) }), cmd!( "find.changeObjects", "Change All (Object)", [], None, "{…the criteria of find.objects, change: {fill?, stroke?, strokeWeight?, opacity?}} → {changed}", has_doc, |s, p| { s.reset_find_scope(); 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?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)", has_doc, find), cmd!( "find.change", "Change All", ["Edit", "Find/Change"], None, "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), first?: bool (change only the first match), found?: bool (Change: only the text Find Next found, while it is selected and still a match; it stays selected, and Find Next goes on after it in the same domain), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted; with no find, change is literal text", has_doc, change ), cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", 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 { 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)) } /// Find/Change's Find Format: the text a search finds must have it all through. #[derive(Clone, Debug, Default, PartialEq, Eq)] struct FindFormat { char_style: Option, para_style: Option, /// Resolved character attributes, as `type.selectionAttrs` reports them. chars: serde_json::Map, } impl FindFormat { fn from_params(p: &Value) -> Result> { let Some(f) = p.get("findFormat").filter(|f| !f.is_null()) else { return Ok(None) }; let o = f.as_object().ok_or_else(|| bad("find", "`findFormat` is an object"))?; // A style is named; a null asks for none. Any other value is refused rather than // searched without. let name = |k: &str| match o.get(k) { None | Some(Value::Null) => Ok(None), Some(Value::String(n)) => Ok(Some(n.clone())), Some(v) => Err(bad("find", format!("findFormat: `{k}` is a style name, not {v}"))), }; let chars = match o.get("chars") { None | Some(Value::Null) => None, Some(Value::Object(m)) => Some(m), Some(v) => return Err(bad("find", format!("findFormat: `chars` is an object of character attributes, not {v}"))), }; // Only attributes text has, kept as the document writes them: `font_style` and // `fontStyle`, or 12 and 12.0, then find the same text. A null asks for nothing. let mut known = designcraft_doc::CharAttrs::default(); for (k, v) in chars.into_iter().flatten() { known.set_json(k, v).map_err(|e| bad("find", format!("findFormat: {e}")))?; } let chars = match serde_json::to_value(&known) { Ok(Value::Object(m)) => m, _ => serde_json::Map::new(), }; let f = Self { char_style: name("charStyle")?, para_style: name("paraStyle")?, chars }; Ok((f != Self::default()).then_some(f)) } /// Styles the document doesn't have are refused, as `find.change` refuses them. fn check(&self, styles: &Styles) -> Result<()> { if let Some(n) = self.char_style.as_ref().filter(|n| styles.char_style(n).is_none()) { return Err(bad("find", format!("no character style `{n}`"))); } if let Some(n) = self.para_style.as_ref().filter(|n| styles.para(n).is_none()) { return Err(bad("find", format!("no paragraph style `{n}`"))); } Ok(()) } /// The text in `r` of `story` that has the format, relative to `r.start`: the longest /// stretches, each paragraph's apart (a paragraph's `\n` isn't in one). fn spans(&self, styles: &Styles, story: &Story, r: &Range) -> Vec> { let mut out: Vec> = Vec::new(); // Paragraphs and runs both go in text order: the runs before a paragraph are passed once. let runs: Vec<_> = story.runs().collect(); let mut first = 0; for (pi, pr) in story.para_ranges().into_iter().enumerate() { let (a, b) = (pr.start.max(r.start), pr.end.min(r.end)); let Some(pf) = story.paras.get(pi) else { continue }; if a >= b || self.para_style.as_ref().is_some_and(|n| *n != pf.style) { continue; } while runs.get(first).is_some_and(|(run, _)| run.end <= a) { first += 1; } let base = (!self.chars.is_empty()).then(|| styles.resolve_para(pf).1); for (run, cf) in runs[first..].iter().take_while(|(run, _)| run.start < b) { let (x, y) = (run.start.max(a), run.end.min(b)); if x >= y || self.char_style.as_ref().is_some_and(|n| *n != cf.style) { continue; } if let Some(base) = &base { let cp = serde_json::to_value(styles.resolve_char(base, cf)).unwrap_or_default(); if !self.chars.iter().all(|(k, v)| cp.get(k).is_some_and(|c| same_value(c, v))) { continue; } } match out.last_mut() { Some(last) if last.end == x - r.start => last.end = y - r.start, _ => out.push(x - r.start..y - r.start), } } } out } } /// Attribute values alike: numbers to within rounding (inside objects and lists too), the rest /// exactly. fn same_value(a: &Value, b: &Value) -> bool { match (a, b) { (Value::Number(_), Value::Number(_)) => match (a.as_f64(), b.as_f64()) { (Some(x), Some(y)) => (x - y).abs() < 1e-6, _ => a == b, }, (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(a, b)| same_value(a, b)), (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same_value(v, w))), _ => a == b, } } /// Whether match `m` lies in one of `spans`. fn within(spans: &[Range], m: &Range) -> bool { spans.iter().any(|s| s.start <= m.start && m.end <= s.end) } /// What a search finds: the pattern's matches (with a Find Format, those whose text has it all /// through), or, with no pattern, the stretches of text with the Find Format. struct Search { re: Option, format: Option, } /// The search of a Find/Change request: an empty `find` needs a Find Format. fn search(p: &Value) -> Result { let format = FindFormat::from_params(p)?; if format.is_some() && str_param(p, "find") == Some("") { return Ok(Search { re: None, format }); } Ok(Search { re: Some(build(p)?), format }) } impl Search { fn check(&self, styles: &Styles) -> Result<()> { self.format.as_ref().map_or(Ok(()), |f| f.check(styles)) } /// Where the Find Format holds in target `r` of story `sid` (of its table cell `cell`, if any; /// `None`: no Find Format). fn spans(&self, d: &Document, sid: StoryId, cell: Option, r: &Range) -> Option>> { let f = self.format.as_ref()?; Some(d.text_story(sid, cell).map(|story| f.spans(&d.styles, story, r)).unwrap_or_default()) } /// Up to `limit` matches in `text`, target `r` of story `sid` (offsets in `text`). fn find_all(&self, d: &Document, sid: StoryId, cell: Option, r: &Range, text: &str, limit: usize) -> Result>> { let spans = self.spans(d, sid, cell, r); let Some(re) = &self.re else { return Ok(spans.unwrap_or_default().into_iter().take(limit).collect()) }; let Some(spans) = spans else { return re.find_all(text, limit).map_err(|e| bad("find", e)) }; Ok(re.find_all(text, usize::MAX).map_err(|e| bad("find", e))?.into_iter().filter(|m| within(&spans, m)).take(limit).collect()) } /// The first match in `text` (target `r` of story `sid`) at or after byte `from`. fn find_at(&self, d: &Document, sid: StoryId, cell: Option, r: &Range, text: &str, from: usize) -> Result>> { let spans = self.spans(d, sid, cell, r); let Some(re) = &self.re else { // From inside a stretch, the rest of it. return Ok(spans.unwrap_or_default().into_iter().find(|s| s.end > from).map(|s| s.start.max(from)..s.end)); }; let mut at = from; loop { let Some(m) = re.find_at(text, at).map_err(|e| bad("find.next", e))? else { return Ok(None) }; if spans.as_ref().is_none_or(|s| within(s, &m)) { return Ok(Some(m)); } // On past it: its end, or a character on from an empty match. at = if m.is_empty() { text.get(m.end..).and_then(|t| t.chars().next()).map_or(text.len() + 1, |c| m.end + c.len_utf8()) } else { m.end }; if at > text.len() { return Ok(None); } } } /// The edit Change makes when the selection `range` (offsets in `text`, target `r` of story /// `sid`) is what Find Next found there, so the two commands agree on what a match is. With /// no pattern: a selection inside one stretch with the Find Format (Find Next selects the rest /// of a stretch from inside it). With a pattern: the match it makes from the selection's start /// must be the selection, overlapping matches Find Next can select included. #[allow(clippy::too_many_arguments)] fn found_edit( &self, d: &Document, sid: StoryId, cell: Option, r: &Range, text: &str, range: &Range, rep: &str, grep: bool, ) -> Result, String)>> { let spans = self.spans(d, sid, cell, r); let Some(re) = &self.re else { let inside = !range.is_empty() && spans.unwrap_or_default().iter().any(|s| s.start <= range.start && range.end <= s.end); return Ok(inside.then(|| (range.clone(), rep.to_string()))); }; let found = if grep { re.replacement_at(text, range.start, rep) } else { re.find_at(text, range.start).map(|m| m.map(|m| (m, rep.to_string()))) }; let found = found.map_err(|e| bad("find.change", e))?; Ok(found.filter(|(m, _)| m == range && spans.as_ref().is_none_or(|s| within(s, m)))) } /// The edits Change All makes in `text` (target `r` of story `sid`): `rep` expanded for each /// GREP match, else `rep` itself. #[allow(clippy::too_many_arguments)] fn replacements( &self, d: &Document, sid: StoryId, cell: Option, r: &Range, text: &str, rep: &str, grep: bool, first_only: bool, ) -> Result, String)>> { let limit = if first_only { 1 } else { usize::MAX }; match (&self.re, self.spans(d, sid, cell, r)) { (Some(re), None) if grep => re.replacements(text, rep, first_only).map_err(|e| bad("find.change", e)), (Some(re), Some(spans)) if grep => Ok(re .replacements(text, rep, false) .map_err(|e| bad("find.change", e))? .into_iter() .filter(|(m, _)| within(&spans, m)) .take(limit) .collect()), _ => Ok(self.find_all(d, sid, cell, r, text, limit)?.into_iter().map(|m| (m, rep.to_string())).collect()), } } } #[derive(Clone, Debug, PartialEq, Eq)] enum Domain { Document, Story, Selection, Explicit(StoryId), } impl Domain { fn from_params(p: &Value) -> Self { if let Some(id) = p.get("story").and_then(Value::as_u64) { return Self::Explicit(StoryId(id)); } match str_param(p, "scope").unwrap_or("document") { "story" => Self::Story, "selection" => Self::Selection, _ => Self::Document, } } } /// Whether two requests use the same search domain, including explicit-story precedence. pub fn same_find_domain(a: &Value, b: &Value) -> bool { Domain::from_params(a) == Domain::from_params(b) } #[derive(Clone, Debug, PartialEq, Eq)] struct Matcher { find: String, grep: bool, case_sensitive: bool, whole_word: bool, format: Option, } impl Matcher { fn from_params(p: &Value) -> Self { Self { find: str_param(p, "find").unwrap_or("").to_string(), grep: bool_or(p, "grep", false), case_sensitive: bool_or(p, "caseSensitive", false), whole_word: bool_or(p, "wholeWord", false), format: FindFormat::from_params(p).ok().flatten(), } } } /// A searched text: a story, or one table cell of it, and the byte range inside. type Target = (StoryId, Option, Range); /// A search owns its original domain, not the selection used to paint the latest hit. /// Weak identity avoids retaining an old document merely for this transient cache. #[derive(Clone)] pub(crate) struct FindContext { document: Weak, uid: u64, revision: u64, selection: Selection, domain: Domain, matcher: Matcher, targets: Vec, order: Vec, target: usize, offset: usize, last_match_end: Option, } impl FindContext { pub(crate) fn is_current(&self, st: &DocState) -> bool { self.uid == st.uid && Weak::ptr_eq(&self.document, &Arc::downgrade(&st.doc)) && self.revision == st.revision && self.selection == st.selection } fn acknowledge(&mut self, st: &DocState) { self.revision = st.revision; self.selection = st.selection.clone(); } /// After Change put `new_len` bytes in place of `old` in story `sid`: the domain keeps the /// changed text and the search goes on after it. fn after_change(&mut self, st: &DocState, sid: StoryId, cell: Option, old: &Range, new_len: usize) { let moved = |x: usize| if x >= old.end { x - old.len() + new_len } else { x }; for (tid, tcell, r) in &mut self.targets { if *tid == sid && *tcell == cell { *r = moved(r.start)..moved(r.end); } } self.offset = old.start + new_len; self.last_match_end = Some(self.offset); let at = self.offset; if let Some(i) = self .order .iter() .position(|i| self.targets.get(*i).is_some_and(|(tid, tcell, r)| *tid == sid && *tcell == cell && r.start <= at && at <= r.end)) { self.target = i; } self.document = Arc::downgrade(&st.doc); self.acknowledge(st); } fn restart(&mut self, st: &DocState) -> Result<()> { self.target = 0; self.last_match_end = None; self.order = (0..self.targets.len()).collect(); let caret = st.selection.text.filter(|_| st.selection.cells.is_none()); // A caret in a searched cell starts there; one in a footnote starts at its story. let start = st.selection.text.and_then(|t| { let at = |want: Option| self.targets.iter().position(|(sid, cell, _)| *sid == t.story && *cell == want); at(t.cell).or_else(|| at(None)) }); if let Some(i) = start.filter(|_| self.domain == Domain::Document) { self.order.rotate_left(i); } let first = self.order.first().and_then(|i| self.targets.get(*i)); self.offset = match first { Some((sid, cell, r)) if self.domain != Domain::Selection => { caret.filter(|t| t.story == *sid && t.cell == *cell).map_or(r.start, |t| t.range().end.min(r.end)) } Some((_, _, r)) => r.start, None => 0, }; if let Some((sid, cell, r)) = first { let text = target_text(&st.doc, *sid, *cell, r)?; if text.get(self.offset.saturating_sub(r.start)..).is_none() { return Err(bad("find", "invalid UTF-8 search cursor")); } } Ok(()) } fn advance_target(&mut self) { self.target = self.target.saturating_add(1); self.last_match_end = None; self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, _, r)| r.start); } fn advance_empty(&mut self, text: &str, relative_end: usize, end: usize) { // Move one Unicode scalar, not one byte; an endpoint is returned at most once. if let Some(ch) = text.get(relative_end..).and_then(|rest| rest.chars().next()) { self.offset = end + ch.len_utf8(); } else { self.advance_target(); } } } fn target_text<'a>(d: &'a Document, sid: StoryId, cell: Option, r: &Range) -> Result<&'a str> { d.text_story(sid, cell) .ok_or_else(|| bad("find", "no such story"))? .text .get(r.clone()) .ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary")) } fn scope(s: &Session, p: &Value) -> Result { let st = s.doc()?; let domain = Domain::from_params(p); let matcher = Matcher::from_params(p); if let Some(previous) = s.find_context.as_ref().filter(|c| c.is_current(st) && c.domain == domain) { let mut context = previous.clone(); if context.matcher != matcher { context.matcher = matcher; context.restart(st)?; } return Ok(context); } let d = &st.doc; let targets = match domain { Domain::Explicit(sid) => { let len = d.story(sid).ok_or_else(|| bad("find", "no such story"))?.len(); vec![(sid, None, 0..len)] } Domain::Story | Domain::Selection => { if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) { return Err(bad("find", "Story/Selection search in table cells or notes is not supported")); } let t = st.selection.text.ok_or_else(|| bad("find", "no text selected"))?; let story = d.story(t.story).ok_or_else(|| bad("find", "no such story"))?; let r = if domain == Domain::Selection { t.range() } else { 0..story.len() }; target_text(d, t.story, None, &r)?; vec![(t.story, None, r)] } // Table cells hold their own text outside `stories`; search them right after the story that anchors the table. Domain::Document => d .stories .values() .flat_map(|x| { let cells = x.tables.iter().flat_map(move |(id, t)| { let cols = t.ncols().max(1); t.cells.iter().enumerate().map(move |(i, c)| (x.id, Some(CellAddr { table: *id, row: i / cols, col: i % cols }), 0..c.text.len())) }); std::iter::once((x.id, None, 0..x.len())).chain(cells) }) .collect(), }; let mut context = FindContext { document: Arc::downgrade(d), uid: st.uid, revision: st.revision, selection: st.selection.clone(), domain, matcher, targets, order: Vec::new(), target: 0, offset: 0, last_match_end: None, }; context.restart(st)?; Ok(context) } /// Most matches `find.find` returns. const MAX_MATCHES: usize = 10_000; fn find(s: &mut Session, p: &Value) -> Result { let re = search(p)?; re.check(&s.doc()?.doc.styles)?; let context = scope(s, p)?; let st = s.doc()?; let mut out = Vec::new(); for (sid, cell, r) in &context.targets { let text = target_text(&st.doc, *sid, *cell, r)?; for m in re.find_all(&st.doc, *sid, *cell, r, text, MAX_MATCHES.saturating_sub(out.len()))? { let mut hit = json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)}); if let Some(c) = cell { hit["cell"] = json!({"table": c.table, "row": c.row, "col": c.col}); } out.push(hit); } } s.find_context = Some(context); 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) } } /// Repair byte validity of a text selection once against the final story, without remapping valid offsets. fn repair_selection(t: &mut designcraft_doc::TextSel, st: &designcraft_doc::Story) { let range = t.range(); let start = floor_char_boundary(&st.text, range.start); let mut end = range.end.min(st.len()); if t.is_caret() { end = start; } else if !st.text.is_char_boundary(end) { let floor = floor_char_boundary(&st.text, end); end = st.text.get(floor..).and_then(|rest| rest.chars().next()).map_or(floor, |ch| floor + ch.len_utf8()); } (t.anchor, t.focus) = if t.anchor <= t.focus { (start, end) } else { (end, start) }; } /// The character overrides a Change applies to every replacement, from its `attrs` object. fn char_overrides(attrs: Option<&Value>) -> Result { let mut over = designcraft_doc::CharAttrs::default(); if let Some(o) = attrs.and_then(Value::as_object) { for (k, v) in o { over.set_json(k, v).map_err(|e| bad("find.change", e))?; } } Ok(over) } fn change(s: &mut Session, p: &Value) -> Result { let re = search(p)?; re.check(&s.doc()?.doc.styles)?; // With no pattern (a Find Format alone), the change is literal text. let grep = bool_or(p, "grep", false) && re.re.is_some(); let rep = if re.re.is_some() { replacement(p) } else { metachars(str_param(p, "change").unwrap_or("")) }; let first_only = bool_or(p, "first", false); let attrs = p.get("attrs").cloned(); let over = char_overrides(attrs.as_ref())?; // A null attribute clears that override on the found text. let cleared: Vec = attrs .as_ref() .and_then(Value::as_object) .map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()) .unwrap_or_default(); super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; let char_style = str_param(p, "charStyle").map(str::to_string); let para_style = str_param(p, "paraStyle").map(str::to_string); let styles = &s.doc()?.doc.styles; if let Some(n) = char_style.as_ref().filter(|n| styles.char_style(n).is_none()) { return Err(bad("find.change", format!("no character style `{n}`"))); } if let Some(n) = para_style.as_ref().filter(|n| styles.para(n).is_none()) { return Err(bad("find.change", format!("no paragraph style `{n}`"))); } // InDesign: with a Change Format and nothing in Change to, the found text is only formatted. let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty()); let context = scope(s, p)?; let targets = context.targets.clone(); // Change: only the text Find Next found, the selection, while it is still a match (as Find // Next defines one), with its replacement. let (found, found_out) = if bool_or(p, "found", false) { let st = s.doc()?; let selected = st.selection.text.filter(|t| st.selection.cells.is_none() && !t.is_caret()).map(|t| (t.story, t.cell, t.range())); let hit = selected.and_then(|(sid, cell, range)| { targets.iter().find_map(|(tid, tcell, r)| { if *tid != sid || *tcell != cell || range.start < r.start || r.end < range.end { return None; } let text = target_text(&st.doc, *tid, cell, r).ok()?; let local = range.start - r.start..range.end - r.start; let (m, out) = re.found_edit(&st.doc, *tid, cell, r, text, &local, &rep, grep).ok()??; Some(((sid, cell, r.start + m.start..r.start + m.end), out)) }) }); match hit { Some((hit, out)) => (Some(hit), Some(out)), None => return Ok(json!({"count": 0})), } } else { (None, None) }; let (count, changed) = s.edit(|d, sel| { let styles = d.styles.clone(); let mut count = 0usize; let mut changed = None; for (sid, cell, r) in targets { if found.as_ref().is_some_and(|(at, in_cell, _)| *at != sid || *in_cell != cell) { continue; } let Some(story) = d.text_story(sid, cell) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?; let edits: Vec<(std::ops::Range, String)> = match (&found, &found_out) { // Change: the found text alone, in the target it lies in. (Some((_, _, range)), Some(out)) => { if r.start <= range.start && range.end <= r.end { vec![(range.clone(), out.clone())] } else { Vec::new() } } _ => re .replacements(d, sid, cell, &r, text, &rep, grep, first_only)? .into_iter() .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) .collect(), }; let Some(st) = d.text_story_mut(sid, cell) else { continue }; for (range, new) in edits.iter().rev() { let changed = if format_only { range.clone() } else { st.replace(range.clone(), new); range.start..range.start + new.len() }; // A paragraph style goes to the match's paragraphs, an empty match's (`^`) too; // character formatting needs characters. if let Some(n) = ¶_style { super::style::apply_para_style(st, &styles, n, &changed, false); } if !changed.is_empty() { if let Some(n) = &char_style { super::style::apply_char_style(st, &styles, n, &changed); } if !over.is_empty() || !cleared.is_empty() { st.format_chars(changed, |f| { f.over.merge(&over); for k in &cleared { let _ = f.over.set_json(k, &Value::Null); } }); } } count += 1; } if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) { repair_selection(t, st); } // Change leaves the changed text selected. if let Some((_, _, range)) = &found && let Some((_, new)) = edits.first() && let Some(t) = sel.text.as_mut() { let len = if format_only { range.len() } else { new.len() }; (t.anchor, t.focus) = (range.start, range.start + len); changed = Some(len); } // `first` is the first match of the whole search, not of each story. if first_only && count > 0 { break; } } Ok((count, changed)) })?; // Change: Find Next goes on after the changed text, in the search's domain. if let (Some((sid, cell, old)), Some(len)) = (&found, changed) { let mut context = context; context.after_change(s.doc()?, *sid, *cell, old, len); s.find_context = Some(context); } Ok(json!({"count": count})) } fn find_next(s: &mut Session, p: &Value) -> Result { let re = search(p)?; re.check(&s.doc()?.doc.styles)?; let mut context = scope(s, p)?; let st = s.doc()?; let mut hit = None; while let Some((sid, cell, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() { let text = target_text(&st.doc, sid, cell, &r)?; let from = context.offset.checked_sub(r.start).ok_or_else(|| bad("find", "invalid search cursor"))?; if text.get(from..).is_none() { return Err(bad("find", "invalid UTF-8 search cursor")); } if let Some(m) = re.find_at(&st.doc, sid, cell, &r, text, from)? { let (a, b) = (r.start + m.start, r.start + m.end); // Match find_iter/captures_iter: suppress an empty match adjacent to the // previous match's end, but keep adjacent nonempty matches. if m.is_empty() && context.last_match_end == Some(b) { context.advance_empty(text, m.end, b); continue; } hit = Some((sid, cell, a, b)); context.last_match_end = Some(b); if m.is_empty() { context.advance_empty(text, m.end, b); } else { context.offset = b; } break; } context.advance_target(); } let Some((sid, cell, a, b)) = hit else { s.find_context = Some(context); return Ok(Value::Null); }; let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied()); let cell_json = cell.map(|c| json!({"table": c.table, "row": c.row, "col": c.col})); let stm = s.doc_mut()?; stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell }); stm.bump_revision(); context.acknowledge(stm); s.find_context = Some(context); let mut out = json!({"story": sid.0, "start": a, "end": b}); if let Some(c) = cell_json { out["cell"] = c; } Ok(out) } #[cfg(test)] mod tests; /// Objects matching Find/Change › Object criteria, in document order (nested ones included). fn find_objects(d: &designcraft_doc::Document, p: &Value) -> Vec { 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; /// A GREP match that is empty (`^`, the start of each paragraph) gets the paragraph style: /// only character formatting needs characters. #[test] fn change_format_styles_paragraphs_of_empty_matches() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let r = s .execute( "frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One Two Three"}), ) .unwrap(); let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); let r = s.execute("find.change", &json!({"find": "^", "change": "", "grep": true, "paraStyle": "Method"})).unwrap(); assert_eq!(r["count"], 3); let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); assert!(story.paras.iter().all(|p| p.style == "Method"), "{:?}", story.paras.iter().map(|p| &p.style).collect::>()); assert_eq!( story.text, "One Two Three" ); } #[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); } }