Files
designcraft/crates/engine/src/cmd/find.rs
T

621 lines
28 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 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, StoryId, 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<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| {
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::<Vec<_>>()}))
}),
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?, change?|first?|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?, first?: bool (change only the first match), 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",
has_doc,
change
),
cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, change?|first?|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<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))
}
#[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,
}
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),
}
}
}
/// A searched text: a story, or one table cell of it, and the byte range inside.
type Target = (StoryId, Option<CellAddr>, Range<usize>);
/// 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<Document>,
uid: u64,
revision: u64,
selection: Selection,
domain: Domain,
matcher: Matcher,
targets: Vec<Target>,
order: Vec<usize>,
target: usize,
offset: usize,
last_match_end: Option<usize>,
}
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();
}
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<CellAddr>| 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<CellAddr>, r: &Range<usize>) -> 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<FindContext> {
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<Value> {
let re = build(p)?;
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(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? {
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<designcraft_doc::CharAttrs> {
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<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 over = char_overrides(attrs.as_ref())?;
// A null attribute clears that override on the found text.
let cleared: Vec<String> =
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 targets = scope(s, p)?.targets;
s.edit(|d, sel| {
let styles = d.styles.clone();
let mut count = 0usize;
for (sid, cell, r) in targets {
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<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.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) = &para_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);
}
// `first` is the first match of the whole search, not of each story.
if first_only && count > 0 {
break;
}
}
Ok(json!({"count": count}))
})
}
fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
let re = build(p)?;
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(text, from).map_err(|e| bad("find.next", e))? {
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<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;
/// 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::<Vec<_>>());
assert_eq!(
story.text,
"One
Two
Three"
);
}
#[test]
fn change_format_styles_the_found_text_and_keeps_it() {
// The course's chapter 22: every lowercase "eggs" gets the character style "eggs", with
// Change to empty, so the words stay.
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let text = "Poached Eggs\nBreak eggs; cook until eggs are set.";
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": text})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("find.change", &json!({"find": "eggs", "change": "", "caseSensitive": true, "charStyle": "eggs"})).unwrap();
assert_eq!(r["count"], 2);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let style_at = |s: &Session, at: usize| s.doc().unwrap().doc.story(sid).unwrap().format_after(at).style.clone();
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert_eq!(story.text, text, "the words stay");
for at in [19, 36] {
assert_eq!(&story.text[at..at + 4], "eggs");
assert_eq!(style_at(&s, at), "eggs");
}
assert_eq!(style_at(&s, 8), designcraft_doc::NO_CHAR_STYLE, "case sensitive: not \"Eggs\"");
assert_eq!(style_at(&s, 25), designcraft_doc::NO_CHAR_STYLE, "only the found text");
// A paragraph style goes to the found text's paragraphs, with attributes over the text.
s.execute("find.change", &json!({"find": "cook", "change": "", "paraStyle": "Method", "attrs": {"underline": true}})).unwrap();
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert_eq!((story.paras[0].style.as_str(), story.paras[1].style.as_str()), (designcraft_doc::BASIC_PARAGRAPH, "Method"));
assert_eq!(story.format_after(25).over.underline, Some(true));
assert_eq!(story.format_after(19).style, "eggs", "the other text keeps its character style");
// Change to and a Change Format: the new text is formatted.
s.execute("find.change", &json!({"find": "set", "change": "firm", "charStyle": "eggs"})).unwrap();
assert_eq!(style_at(&s, s.doc().unwrap().doc.story(sid).unwrap().text.find("firm").unwrap()), "eggs");
// No such style: refused, nothing changed.
let before = s.doc().unwrap().doc.clone();
assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "charStyle": "Nope"})).is_err());
assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "paraStyle": "Nope"})).is_err());
assert_eq!(s.doc().unwrap().doc, before);
// A null attribute is a Change Format too: the found text stays and loses that override.
let before = s.doc().unwrap().doc.story(sid).unwrap().clone();
s.execute("find.change", &json!({"find": "cook", "change": "", "attrs": {"underline": null}})).unwrap();
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert_eq!(story.text, before.text, "the words stay");
assert_eq!(story.format_after(25).over.underline, None);
// With no Change Format, an empty Change to still deletes the found text.
s.execute("find.change", &json!({"find": "Break ", "change": ""})).unwrap();
assert!(!s.doc().unwrap().doc.story(sid).unwrap().text.contains("Break"));
}
#[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);
}
}