mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 22:26:49 -06:00
621 lines
28 KiB
Rust
621 lines
28 KiB
Rust
//! 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) = ¶_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);
|
||
}
|
||
}
|