From 7484a6bd11d968b3cd415716c298453bdabe9e87 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 03:15:31 +0200 Subject: [PATCH] =?UTF-8?q?Find=20and=20Replace:=20special=20characters=20?= =?UTF-8?q?(^p,=20^t,=20=E2=80=A6)=20and=20wildcards=20(#543)=20(#553)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Find what understands Word's ^ codes (^p ^t ^? ^# ^$ ^^ ^l ^m ^n ^b ^+ ^= ^s ^~ ^- ^w ^g ^% ^v, ^nnn / ^unnnn); Replace with adds ^& (found text) and ^c (clipboard). `special: false` reads the text literally. - Use wildcards: ? * [abc] [a-z] [!x] {n} {n,} {n,m} @ < > ( ), \1-\9 in Replace with; always matches case, like Word. Patterns are translated to the linear-time regex engine (no catastrophic backtracking), length and counts capped, bad patterns are errors. - ^p matches paragraph marks between sibling paragraphs; a ^p in the replacement splits the paragraph. Replace All stays one undo step. - Match prefix / suffix, Ignore punctuation / white-space characters. - Dialog: Use wildcards checkbox, Search Options with the new options and a Special menu that inserts codes into the focused field (a shorter list with wildcards); strings in all 9 catalogs. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/engine/src/cmd/edit.rs | 203 ++++++++++--- crates/engine/src/cmd/find_pattern.rs | 417 ++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/session.rs | 8 + crates/engine/src/tests.rs | 69 +++++ crates/ui-egui/src/dialogs.rs | 172 ++++++++++- crates/ui-egui/src/i18n/es.tsv | 32 +- crates/ui-egui/src/i18n/et.tsv | 32 +- crates/ui-egui/src/i18n/ja.tsv | 32 +- crates/ui-egui/src/i18n/pt-br.tsv | 32 +- crates/ui-egui/src/i18n/ru.tsv | 33 +- crates/ui-egui/src/i18n/sr-latn.tsv | 32 +- crates/ui-egui/src/i18n/sr.tsv | 32 +- crates/ui-egui/src/i18n/tr.tsv | 33 +- crates/ui-egui/src/i18n/uk.tsv | 32 +- crates/ui-egui/src/i18n/zh-hans.tsv | 32 +- crates/ui-egui/src/i18n/zh-hant.tsv | 32 +- docs/ui-parity.md | 3 +- 19 files changed, 1169 insertions(+), 60 deletions(-) create mode 100644 crates/engine/src/cmd/find_pattern.rs diff --git a/ROADMAP.md b/ROADMAP.md index 3a89f8c..f31b96d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) | | 2026-10-11 | Typing parity (#546): `+---+` Enter makes a table, `> ` `-> ` `=> ` arrow bullets, smileys inside words (`ok:)`), Undo right after a border line brings the characters back | | 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, built into Windows, so the app no longer quits at launch with "Parent device is lost" when no matching `dxcompiler.dll` + `dxil.dll` pair is around (#603) | | 2026-10-11 | Insert › SmartArt (#497): list, process, cycle, hierarchy, pyramid, radial, matrix and Venn graphics laid out by our own code, Text Pane, SmartArt Design tab (Add Shape, Promote, Demote, layouts, colours, Reset); saved as DOCX diagram parts with the drawing. Catalog 410/441 (93.0%) | @@ -179,6 +180,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) | | 2026-10-11 | trivial | Progress log: Windows DX12 shaders compiled with FXC (#603) | | 2026-10-11 | trivial | Progress log: Insert › SmartArt (#497); catalog 429/451 → 437/458 (95.4%) | | 2026-10-11 | trivial | Progress log: Outline view (#493) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 26a2837..0a0682b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use wordcraft_doc::edit::Fragment; use wordcraft_doc::{Pos, StoryRef}; -use super::{delete_selection, pos_json, sel_result}; +use super::{delete_selection, find_pattern, pos_json, sel_result}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; fn has_sel(s: &Session) -> Option<&'static str> { @@ -65,19 +65,19 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("edit.find", "Find", "Home › Editing", find) .key("Mod+F") - .params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#) + .params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "matchPrefix"?: bool, "matchSuffix"?: bool, "ignorePunct"?: bool, "ignoreSpace"?: bool, "wildcards"?: bool, "special"?: bool (^p ^t ^# … codes, default true), "regex"?: bool}"#) .pure(), CommandSpec::new("edit.advancedFind", "Advanced Find", "Home › Editing › Find", advanced_find) - .params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool, "in"?: "main|selection", "highlight"?: bool}"#) + .params(r#"{"text"?: string, …options of edit.find, "in"?: "main|selection", "highlight"?: bool}"#) .pure(), CommandSpec::new("edit.findNext", "Find Next", "Home › Editing › Find", |s, _| step(s, 1)).key("Mod+G / F3 / Mod+PageDown").pure(), CommandSpec::new("edit.findPrevious", "Find Previous", "Home › Editing › Find", |s, _| step(s, -1)) // Elsewhere Ctrl+Shift+G is Word Count; Ctrl+Page Up/Down browse the find results. .key(super::mac_or("Mod+Shift+G / Mod+PageUp", "Mod+PageUp")) .pure(), - CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string}"#), + CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string, …options of edit.replaceAll}"#), CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all) - .params(r#"{"text": string, "with": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#), + .params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#), CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto) .key("Mod+Alt+G / F5") .params(r#"{"page"?: n, "bookmark"?: string, "paragraph"?: n}"#) @@ -278,46 +278,153 @@ fn paste_text(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } +/// One match: its range and, when asked for, the Replace with text filled in for it. +pub(crate) struct Found { + pub a: Pos, + pub b: Pos, + pub rep: Option, +} + +/// A stretch of searched text: `len` bytes from `hay` are paragraph `para`'s bytes from `raw`. +/// A paragraph mark is a one-byte stretch at the paragraph's end (`mark`). +struct Seg { + hay: usize, + para: usize, + raw: usize, + len: usize, + mark: bool, +} + /// All matches of the find state in the current story. Text marked deleted by /// Track Changes is no longer part of the document, so the search reads each /// paragraph without it: a match lies within one stretch of live text, and word /// boundaries and anchors see the neighbouring live text, not the deleted text. /// Otherwise Replace would find its own deleted text again. pub(crate) fn search(s: &Session, story: StoryRef) -> Result, CmdError> { + Ok(search_full(s, story, false)?.into_iter().map(|f| (f.a, f.b)).collect()) +} + +fn find_opts(f: &crate::session::FindState) -> find_pattern::Opts { + find_pattern::Opts { + match_case: f.match_case, + whole_word: f.whole_word, + prefix: f.match_prefix, + suffix: f.match_suffix, + ignore_punct: f.ignore_punct, + ignore_space: f.ignore_space, + wildcards: f.wildcards, + regex: f.regex, + literal: f.literal, + } +} + +/// [`search`], with the Replace with text of each match when `with_rep` (special codes, `^&`, +/// wildcard groups). A pattern that can match a paragraph mark (`^p`) reads runs of sibling +/// paragraphs as one text with a mark between them; otherwise each paragraph is read alone. +pub(crate) fn search_full(s: &Session, story: StoryRef, with_rep: bool) -> Result, CmdError> { use regex_automata::{Input, meta, util::syntax}; let f = &s.find; if f.query.is_empty() { return Ok(Vec::new()); } - let pat = if f.regex { f.query.clone() } else { regex::escape(&f.query) }; - let pat = if f.whole_word { format!(r"\b{pat}\b") } else { pat }; + let opts = find_opts(f); + let c = find_pattern::compile(&f.query, &opts).map_err(CmdError::Params)?; let re = meta::Regex::builder() - .syntax(syntax::Config::new().case_insensitive(!f.match_case)) + .syntax(syntax::Config::new().case_insensitive(c.case_insensitive)) .configure(meta::Config::new().nfa_size_limit(Some(1 << 20))) - .build(&pat) + .build(&c.pattern) .map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?; - let mut out = Vec::new(); - for path in s.doc.para_paths(story) { - let Some(p) = s.doc.para(story, &path) else { continue }; - // The paragraph as it reads, and its stretches of live text as - // (start in `live`, start in the paragraph, length). - let mut live = String::new(); - let mut spans: Vec<(usize, usize, usize)> = Vec::new(); - for (r, c) in p.run_ranges() { - let Some(t) = p.text.get(r.clone()).filter(|_| c.del.is_none()) else { continue }; - match spans.last_mut() { - Some((_, raw, n)) if *raw + *n == r.start => *n += t.len(), - _ => spans.push((live.len(), r.start, t.len())), - } - live.push_str(t); + let pieces = with_rep.then(|| find_pattern::parse_replace(&f.replace, &opts)); + let paths = s.doc.para_paths(story); + // Paragraphs read as one text: consecutive siblings when the pattern spans paragraphs. + let mut chunks: Vec> = Vec::new(); + for i in 0..paths.len() { + let joins = + c.spans_paras && i > 0 && paths.get(i - 1).zip(paths.get(i)).is_some_and(|(x, y)| x.parent() == y.parent() && x.last() + 1 == y.last()); + match chunks.last_mut() { + Some(r) if joins => r.end = i + 1, + _ => chunks.push(i..i + 1), } - for (at, raw, n) in spans { - for m in re.find_iter(Input::new(&live).range(at..at + n)) { + } + let mut out = Vec::new(); + let mut caps = re.create_captures(); + for chunk in chunks { + let mut hay = String::new(); + let mut segs: Vec = Vec::new(); + // Where the live text breaks (tracked-deleted text, or a paragraph edge when not spanning). + let mut cuts: Vec = Vec::new(); + let last = chunk.end.saturating_sub(1); + for i in chunk { + let Some(p) = paths.get(i).and_then(|path| s.doc.para(story, path)) else { + cuts.push(hay.len()); + continue; + }; + let mut expect = 0; + for (r, ch) in p.run_ranges() { + let Some(t) = p.text.get(r.clone()).filter(|_| ch.del.is_none()) else { continue }; + if t.is_empty() { + continue; + } + if r.start != expect { + cuts.push(hay.len()); + } + match segs.last_mut() { + Some(sg) if !sg.mark && sg.para == i && sg.raw + sg.len == r.start => sg.len += t.len(), + _ => segs.push(Seg { hay: hay.len(), para: i, raw: r.start, len: t.len(), mark: false }), + } + hay.push_str(t); + expect = r.end; + } + if i < last { + if expect != p.text.len() { + cuts.push(hay.len()); + } + segs.push(Seg { hay: hay.len(), para: i, raw: p.text.len(), len: 1, mark: true }); + hay.push(if p.section.is_some() { find_pattern::SECT_MARK } else { find_pattern::PARA_MARK }); + } + } + let pos_of = |x: usize, end: bool| -> Option { + let k = segs.partition_point(|sg| if end { sg.hay + sg.len < x } else { sg.hay + sg.len <= x }); + let sg = segs.get(k)?; + if (end && sg.hay >= x) || (!end && sg.hay > x) { + return None; + } + if sg.mark && x > sg.hay { + let path = paths.get(sg.para)?; + return Some(Pos { story, path: path.with_last(path.last() + 1), off: 0 }); + } + Some(Pos { story, path: paths.get(sg.para)?.clone(), off: sg.raw + (x - sg.hay) }) + }; + cuts.push(hay.len()); + let mut from = 0; + for cut in cuts { + if cut <= from { + continue; + } + let mut input = Input::new(&hay).range(from..cut); + from = cut; + loop { + re.search_captures(&input, &mut caps); + let Some(m) = caps.get_match() else { break }; + // Step past an empty match so the search moves on. + let next = if m.is_empty() { + hay.get(m.end()..).and_then(|t| t.chars().next()).map_or(m.end() + 1, |c| m.end() + c.len_utf8()) + } else { + m.end() + }; + if next > input.end() { + break; + } + input.set_start(next); if m.is_empty() { continue; } - let (a, b) = (raw + m.start().saturating_sub(at), raw + m.end().saturating_sub(at)); - out.push((Pos { story, path: path.clone(), off: a }, Pos { story, path: path.clone(), off: b })); + let (Some(a), Some(b)) = (pos_of(m.start(), false), pos_of(m.end(), true)) else { continue }; + let rep = pieces.as_ref().map(|pc| { + let group = |n: usize| caps.get_group(n).and_then(|g| hay.get(g.range())).map(str::to_string); + find_pattern::expand(pc, hay.get(m.range()).unwrap_or(""), &group, &s.clipboard_text) + }); + out.push(Found { a, b, rep }); if out.len() >= 100_000 { return Ok(out); } @@ -342,6 +449,24 @@ fn read_opts(s: &mut Session, v: &Value) { if let Some(b) = p::bool(v, "regex") { s.find.regex = b; } + if let Some(b) = p::bool(v, "wildcards") { + s.find.wildcards = b; + } + if let Some(b) = p::bool(v, "special") { + s.find.literal = !b; + } + if let Some(b) = p::bool(v, "matchPrefix") { + s.find.match_prefix = b; + } + if let Some(b) = p::bool(v, "matchSuffix") { + s.find.match_suffix = b; + } + if let Some(b) = p::bool(v, "ignorePunct") { + s.find.ignore_punct = b; + } + if let Some(b) = p::bool(v, "ignoreSpace") { + s.find.ignore_space = b; + } if let Some(t) = p::str(v, "with") { s.find.replace = t.to_string(); } @@ -421,25 +546,25 @@ fn step(s: &mut Session, dir: i64) -> CmdResult { fn replace(s: &mut Session, v: &Value) -> CmdResult { read_opts(s, v); let story = s.sel.focus.story; - let results = search(s, story)?; + let results = search_full(s, story, true)?; let (a, b) = s.sel.ordered(); // Replace the current match if selected, then move to the next. - if results.iter().any(|(x, y)| *x == a && *y == b) { - let with = s.find.replace.clone(); - super::paste_text(s, &with)?; + if let Some(f) = results.into_iter().find(|f| f.a == a && f.b == b) { + super::paste_text(s, &f.rep.unwrap_or_default())?; } let _ = step(s, 1); Ok(json!({"remaining": search(s, story)?.len()})) } +/// Every match replaced, last first so earlier positions stay valid (one undo step). A `\r` in +/// the replacement (`^p`) splits the paragraph. fn replace_all(s: &mut Session, v: &Value) -> CmdResult { read_opts(s, v); let story = s.sel.focus.story; - let results = search(s, story)?; - let with = s.find.replace.clone(); + let results = search_full(s, story, true)?; let n = results.len(); - // Replace from the end so earlier positions stay valid. - for (a, b) in results.into_iter().rev() { + for Found { a, b, rep } in results.into_iter().rev() { + let with = rep.unwrap_or_default(); if s.doc.settings.track_changes { // Same path as a single replace, so the change is a reviewable revision. s.sel = Selection { anchor: a, focus: b }; @@ -447,11 +572,9 @@ fn replace_all(s: &mut Session, v: &Value) -> CmdResult { continue; } let props = s.doc.para_at(&a).map(|p| p.props_of_char(a.off).clone()).unwrap_or_default(); - s.doc.delete_range(&a, &b)?; - // A paragraph mark in the replacement starts a new paragraph, as in a single replace. - let mut at = a; - for (i, part) in with.split(['\r', '\u{2029}']).enumerate() { - if i > 0 { + let mut at = s.doc.delete_range(&a, &b)?; + for (k, part) in with.split(['\r', '\u{2029}']).enumerate() { + if k > 0 { at = s.doc.split_paragraph(&at)?; } if !part.is_empty() { diff --git a/crates/engine/src/cmd/find_pattern.rs b/crates/engine/src/cmd/find_pattern.rs new file mode 100644 index 0000000..2c39bca --- /dev/null +++ b/crates/engine/src/cmd/find_pattern.rs @@ -0,0 +1,417 @@ +//! Word's Find and Replace patterns (#543): special characters (`^p`, `^t`, `^#` …) and the +//! "Use wildcards" language (`?`, `*`, `[a-z]`, `{n,m}`, `@`, `<`, `>`, `( )`), translated to a +//! regular expression for the linear-time `regex` engine, so no pattern can backtrack +//! catastrophically. Replace text understands the same codes plus `^&` (the found text), `^c` +//! (the clipboard) and, with wildcards, `\1`–`\9` (the groups). +//! +//! The search reads a story as text with a paragraph mark (`\r`, [`PARA_MARK`]) between +//! paragraphs when the pattern can match one, so `^p` finds paragraph ends; a paragraph that ends +//! a section ends with [`SECT_MARK`] instead (`^b`). + +use regex::escape; + +/// A paragraph mark in the searched text. +pub const PARA_MARK: char = '\r'; +/// The mark of a paragraph that ends a section (`^b`), in the searched text only. +pub const SECT_MARK: char = '\u{1D}'; +/// Longest Find what text, in characters. +pub const MAX_PATTERN: usize = 1024; +/// Largest count in `{n,m}`. +const MAX_COUNT: u32 = 1000; +/// Any character except a paragraph mark (`?`, `^?`, `*`). +const ANY: &str = r"[^\r\x{1D}]"; + +/// How the Find what text is read. +#[derive(Clone, Copy, Debug, Default)] +pub struct Opts { + pub match_case: bool, + pub whole_word: bool, + pub prefix: bool, + pub suffix: bool, + pub ignore_punct: bool, + pub ignore_space: bool, + /// Word's "Use wildcards". + pub wildcards: bool, + /// A regular expression (WordCraft's own option; wins over wildcards). + pub regex: bool, + /// Take the text literally: no `^` codes. + pub literal: bool, +} + +/// A pattern ready for the regex engine. +#[derive(Debug)] +pub struct Compiled { + pub pattern: String, + pub case_insensitive: bool, + /// The pattern can match a paragraph mark, so the search must read across paragraphs. + pub spans_paras: bool, +} + +/// What a `^` code stands for in Find what. +fn find_code(c: char) -> Option<&'static str> { + Some(match c { + 'p' => r"\r", + 'b' => r"\x{1D}", + 't' => r"\t", + '?' => ANY, + '#' => "[0-9]", + '$' => r"\p{L}", + '^' => r"\^", + 'l' => r"\n", + 'm' => r"\x{0C}", + 'n' => r"\x{0E}", + '+' => r"\x{2014}", + '=' => r"\x{2013}", + 's' => r"\x{A0}", + '~' => r"\x{2011}", + '-' => r"\x{AD}", + 'w' => r"[ \t\x{A0}]+", + 'g' => r"\x{FFFC}", + '%' => "§", + 'v' => "¶", + _ => return None, + }) +} + +/// What a `^` code stands for in Replace with. +fn replace_code(c: char) -> Option { + Some(match c { + 'p' => PARA_MARK, + 't' => '\t', + '^' => '^', + 'l' => '\n', + 'm' => '\u{0C}', + 'n' => '\u{0E}', + '+' => '\u{2014}', + '=' => '\u{2013}', + 's' => '\u{A0}', + '~' => '\u{2011}', + '-' => '\u{AD}', + '%' => '§', + 'v' => '¶', + _ => return None, + }) +} + +/// A character code (`^13`, `^0151`, `^u8212`) as the character it stands for here. +fn code_char(n: u32) -> Option { + Some(match n { + 13 => PARA_MARK, + 11 => '\n', + 12 => '\u{0C}', + 14 => '\u{0E}', + 30 => '\u{2011}', + 31 => '\u{AD}', + n => char::from_u32(n).filter(|c| !c.is_control() || *c == '\t')?, + }) +} + +/// Read a numeric code after `^` (`13`, `0151`, `u8212`) starting at `chars[i]`; returns the +/// character and how many characters it took. +fn numeric_code(chars: &[char], i: usize) -> Option<(char, usize)> { + let (start, max) = if chars.get(i) == Some(&'u') { (i + 1, 5) } else { (i, 4) }; + let digits: String = chars.get(start..).unwrap_or(&[]).iter().take(max).take_while(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + return None; + } + let n: u32 = digits.parse().ok()?; + Some((code_char(n)?, start - i + digits.chars().count())) +} + +/// A literal character as a regex atom. +fn lit(c: char) -> String { + if c == PARA_MARK { + return r"\r".into(); + } + escape(c.encode_utf8(&mut [0; 4])) +} + +/// Translate Word's Find what text. +pub fn compile(query: &str, o: &Opts) -> Result { + if query.chars().count() > MAX_PATTERN { + return Err(format!("the Find what text is longer than {MAX_PATTERN} characters")); + } + let (pat, spans) = if o.regex { + (query.to_string(), false) + } else if o.literal { + (escape(query), false) + } else if o.wildcards { + wildcards(query)? + } else { + special(query, o) + }; + let wrap = |p: String| format!("(?:{p})"); + let pat = if o.wildcards && !o.regex { + pat + } else if o.whole_word { + format!(r"\b{}\b", wrap(pat)) + } else { + match (o.prefix, o.suffix) { + (true, true) => format!(r"\b{}\b", wrap(pat)), + (true, false) => format!(r"\b{}", wrap(pat)), + (false, true) => format!(r"{}\b", wrap(pat)), + (false, false) => pat, + } + }; + // Word's wildcard search always matches case. + let case_insensitive = !o.match_case && !(o.wildcards && !o.regex); + Ok(Compiled { pattern: pat, case_insensitive, spans_paras: spans }) +} + +/// Normal search: literal text with `^` codes, optionally skipping punctuation and white space. +fn special(query: &str, o: &Opts) -> (String, bool) { + let chars: Vec = query.chars().collect(); + let mut atoms: Vec = Vec::new(); + let mut spans = false; + let mut i = 0; + while let Some(&c) = chars.get(i) { + i += 1; + if c == '^' + && let Some(&n) = chars.get(i) + { + if let Some(code) = find_code(n) { + spans |= matches!(n, 'p' | 'b'); + atoms.push(code.to_string()); + i += 1; + continue; + } + if let Some((ch, used)) = numeric_code(&chars, i) { + spans |= ch == PARA_MARK; + atoms.push(lit(ch)); + i += used; + continue; + } + // Not a code: the caret itself. + } + if (o.ignore_punct && is_punct(c)) || (o.ignore_space && is_space(c)) { + continue; + } + atoms.push(lit(c)); + } + let filler = match (o.ignore_punct, o.ignore_space) { + (true, true) => r"[\p{P} \t\x{A0}]*", + (true, false) => r"\p{P}*", + (false, true) => r"[ \t\x{A0}]*", + (false, false) => "", + }; + (atoms.join(filler), spans) +} + +fn is_punct(c: char) -> bool { + c.is_ascii_punctuation() || matches!(c, '\u{2010}'..='\u{2027}' | '\u{00A1}' | '\u{00BF}' | '\u{00AB}' | '\u{00BB}') +} + +fn is_space(c: char) -> bool { + matches!(c, ' ' | '\t' | '\u{A0}') +} + +/// "Use wildcards" search. +fn wildcards(query: &str) -> Result<(String, bool), String> { + let chars: Vec = query.chars().collect(); + let mut out = String::new(); + let mut spans = false; + let mut i = 0; + while let Some(&c) = chars.get(i) { + i += 1; + match c { + '?' => out.push_str(ANY), + '*' => { + out.push_str(ANY); + out.push_str("*?"); + } + '@' => out.push('+'), + '<' => out.push_str(r"\b{start}"), + '>' => out.push_str(r"\b{end}"), + '(' | ')' => out.push(c), + '{' => { + let end = chars.get(i..).and_then(|r| r.iter().position(|c| *c == '}')).ok_or("a { needs a closing }")?; + let body: String = chars.get(i..i + end).unwrap_or(&[]).iter().collect(); + out.push_str(&count(&body)?); + i += end + 1; + } + '[' => { + let end = chars.get(i..).and_then(|r| r.iter().position(|c| *c == ']')).ok_or("a [ needs a closing ]")?; + out.push_str(&class(chars.get(i..i + end).unwrap_or(&[]))?); + i += end + 1; + } + '\\' => match chars.get(i) { + Some(d) if d.is_ascii_digit() => return Err("back-references (\\1) work in Replace with, not in Find what".into()), + Some(&n) => { + out.push_str(&lit(n)); + i += 1; + } + None => out.push_str(r"\\"), + }, + '^' => { + let Some(&n) = chars.get(i) else { + out.push_str(r"\^"); + continue; + }; + if let Some(code) = find_code(n) { + spans |= matches!(n, 'p' | 'b'); + out.push_str(code); + i += 1; + } else if let Some((ch, used)) = numeric_code(&chars, i) { + spans |= ch == PARA_MARK; + out.push_str(&lit(ch)); + i += used; + } else { + out.push_str(r"\^"); + } + } + c => out.push_str(&lit(c)), + } + } + Ok((out, spans)) +} + +/// `{n}`, `{n,}`, `{n,m}` (and `{,m}`). +fn count(body: &str) -> Result { + let bad = || format!("{{{body}}} is not a valid count"); + let num = |s: &str| -> Result, String> { + let s = s.trim(); + if s.is_empty() { + return Ok(None); + } + let n: u32 = s.parse().map_err(|_| bad())?; + if n > MAX_COUNT { + return Err(format!("counts go up to {MAX_COUNT}")); + } + Ok(Some(n)) + }; + match body.split_once([',', ';']) { + None => Ok(format!("{{{}}}", num(body)?.ok_or_else(bad)?)), + Some((a, b)) => { + let a = num(a)?.unwrap_or(0); + match num(b)? { + Some(b) if b < a => Err(bad()), + Some(b) => Ok(format!("{{{a},{b}}}")), + None => Ok(format!("{{{a},}}")), + } + } + } +} + +/// `[abc]`, `[a-z]`, `[!x]`: the characters inside are literal. +fn class(inner: &[char]) -> Result { + let (neg, inner) = match inner.split_first() { + Some(('!', rest)) => (true, rest), + _ => (false, inner), + }; + if inner.is_empty() { + return Err("[ ] needs at least one character".into()); + } + let mut out = String::from(if neg { r"[^\r\x{1D}" } else { "[" }); + let mut i = 0; + while let Some(&c) = inner.get(i) { + let c = if c == '\\' && i + 1 < inner.len() { + i += 1; + inner.get(i).copied().unwrap_or(c) + } else { + c + }; + i += 1; + if inner.get(i) == Some(&'-') + && let Some(&hi) = inner.get(i + 1) + { + if hi < c { + return Err(format!("[{c}-{hi}] is not in ascending order")); + } + out.push_str(&format!("{}-{}", lit(c), lit(hi))); + i += 2; + } else { + out.push_str(&lit(c)); + } + } + out.push(']'); + Ok(out) +} + +/// A piece of Replace with text. +#[derive(Clone, Debug, PartialEq)] +pub enum Piece { + Lit(String), + /// `^&`: the text found. + Found, + /// `\n` with wildcards: group `n`. + Group(usize), + /// `^c`: the clipboard. + Clip, +} + +/// Read Replace with text: `^` codes (unless `literal`), and `\1`–`\9` with wildcards. +pub fn parse_replace(with: &str, o: &Opts) -> Vec { + if o.literal || o.regex { + return vec![Piece::Lit(with.to_string())]; + } + let chars: Vec = with.chars().collect(); + let mut out = Vec::new(); + let mut cur = String::new(); + let flush = |cur: &mut String, out: &mut Vec| { + if !cur.is_empty() { + out.push(Piece::Lit(std::mem::take(cur))); + } + }; + let mut i = 0; + while let Some(&c) = chars.get(i) { + i += 1; + if c == '\\' && o.wildcards { + match chars.get(i) { + Some(d) if d.is_ascii_digit() && *d != '0' => { + flush(&mut cur, &mut out); + out.push(Piece::Group(d.to_digit(10).unwrap_or(0) as usize)); + i += 1; + continue; + } + Some(&n) => { + cur.push(n); + i += 1; + continue; + } + None => {} + } + } + if c == '^' + && let Some(&n) = chars.get(i) + { + if n == '&' || n == 'c' { + flush(&mut cur, &mut out); + out.push(if n == '&' { Piece::Found } else { Piece::Clip }); + i += 1; + continue; + } + if let Some(ch) = replace_code(n) { + cur.push(ch); + i += 1; + continue; + } + if let Some((ch, used)) = numeric_code(&chars, i) { + cur.push(ch); + i += used; + continue; + } + } + cur.push(c); + } + flush(&mut cur, &mut out); + out +} + +/// Searched text as it is inserted again: marks become paragraph breaks. +fn as_inserted(s: &str) -> String { + s.replace(SECT_MARK, "\r") +} + +/// Fill in Replace with text for one match: `found` is the matched text, `group(n)` a group's. +pub fn expand(pieces: &[Piece], found: &str, group: &dyn Fn(usize) -> Option, clip: &str) -> String { + let mut out = String::new(); + for p in pieces { + match p { + Piece::Lit(t) => out.push_str(t), + Piece::Found => out.push_str(&as_inserted(found)), + Piece::Group(n) => out.push_str(&as_inserted(&group(*n).unwrap_or_default())), + Piece::Clip => out.push_str(&clip.replace("\r\n", "\r").replace('\n', "\r")), + } + } + out +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index cd3c14e..5bc81a5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -14,6 +14,7 @@ pub mod edit; pub mod equation; pub mod fields; pub mod file; +pub mod find_pattern; pub mod fmt_revisions; pub mod format; pub mod index; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 602f9dc..bf2a51c 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -151,6 +151,14 @@ pub struct FindState { pub match_case: bool, pub whole_word: bool, pub regex: bool, + /// Word's "Use wildcards" (#543). + pub wildcards: bool, + /// Take Find what literally: no `^` codes (`special: false`). + pub literal: bool, + pub match_prefix: bool, + pub match_suffix: bool, + pub ignore_punct: bool, + pub ignore_space: bool, /// Matches from the last search: (start, end). #[serde(skip)] pub results: Vec<(Pos, Pos)>, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ada86c4..a77dc6b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -497,6 +497,75 @@ fn find_replace() { assert_eq!(r["count"], 4); } +#[test] +fn find_replace_special_codes() { + // ^p finds paragraph marks across paragraphs; Replace All with ^p splits paragraphs and is one undo step. + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "one\ntwo\nthree"})); + let paras = |s: &Session| s.doc.para_paths(StoryRef::Body).len(); + assert_eq!(run(&mut s, "edit.find", json!({"text": "^p"}))["count"], 2); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "o^pt", "with": "O+T"}))["replaced"], 1); + assert_eq!((text(&s).as_str(), paras(&s)), ("one\ntwO+Three", 2)); + run(&mut s, "edit.undo", json!({})); + assert_eq!((text(&s).as_str(), paras(&s)), ("one\ntwo\nthree", 3)); + run(&mut s, "document.setText", json!({"text": "a, b, c"})); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": ", ", "with": "^p"}))["replaced"], 2); + assert_eq!((text(&s).as_str(), paras(&s)), ("a\nb\nc", 3)); + run(&mut s, "edit.undo", json!({})); + assert_eq!((text(&s).as_str(), paras(&s)), ("a, b, c", 1)); + // ^t, ^# and ^& (the found text), ^^ is a caret. + run(&mut s, "document.setText", json!({"text": "x\ty 12 a^b"})); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^t", "with": " "}))["replaced"], 1); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^#", "with": "<^&>"}))["replaced"], 2); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^^", "with": "-"}))["replaced"], 1); + assert_eq!(text(&s), "x y <1><2> a-b"); + // `special: false` reads the text literally. + assert_eq!(run(&mut s, "edit.find", json!({"text": "^#", "special": false}))["count"], 0); +} + +#[test] +fn find_replace_wildcards() { + let mut s = s(); + // Groups and back-references swap words; Use wildcards matches case. + run(&mut s, "document.setText", json!({"text": "Smith, John and Doe, Jane; smith, x"})); + let r = run(&mut s, "edit.replaceAll", json!({"text": "([A-Z][a-z]@), ([A-Z][a-z]@)", "with": "\\2 \\1", "wildcards": true})); + assert_eq!(r["replaced"], 2); + assert_eq!(text(&s), "John Smith and Jane Doe; smith, x"); + // < and > anchor to word starts and ends; * is the shortest run; {n} counts. + run(&mut s, "document.setText", json!({"text": "cat concat cats scatter"})); + assert_eq!(run(&mut s, "edit.find", json!({"text": "", "wildcards": true}))["count"], 2); + assert_eq!(run(&mut s, "edit.find", json!({"text": "", "wildcards": true}))["count"], 2); + assert_eq!(run(&mut s, "edit.find", json!({"text": "t{2}", "wildcards": true}))["count"], 1); + assert_eq!(run(&mut s, "edit.find", json!({"text": "[!c]at", "wildcards": true}))["count"], 0); + assert_eq!(run(&mut s, "edit.find", json!({"text": "[!s ]cat", "wildcards": true}))["count"], 1); + // Options of the normal search: prefix, suffix, ignore punctuation. + assert_eq!(run(&mut s, "edit.find", json!({"text": "cat", "wildcards": false, "matchPrefix": true}))["count"], 2); + assert_eq!(run(&mut s, "edit.find", json!({"text": "cat", "matchPrefix": false, "matchSuffix": true}))["count"], 2); + run(&mut s, "document.setText", json!({"text": "e.g. eg"})); + assert_eq!(run(&mut s, "edit.find", json!({"text": "eg", "matchSuffix": false, "ignorePunct": true}))["count"], 2); +} + +#[test] +fn hostile_find_patterns_are_bounded() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": &"a".repeat(5000)})); + // Patterns that backtrack catastrophically elsewhere finish quickly (linear-time engine). + let t = std::time::Instant::now(); + for q in ["(a@)@b", "(*)@b", "(a{1,1000}){1,1000}", "(((((a*)*)*)*)*)b"] { + let _ = s.run("edit.find", &json!({"text": q, "wildcards": true})); + } + assert!(t.elapsed().as_secs() < 20); + // Bad and oversized patterns are errors, not crashes. + for q in ["[z-a]", "a{5", "(ab", "\\1", "x{2000}", "[", "@"] { + assert!(s.run("edit.find", &json!({"text": q, "wildcards": true})).is_err(), "{q}"); + } + assert!(s.run("edit.find", &json!({"text": "x".repeat(2000), "wildcards": false})).is_err()); + for q in ["^", "^p^", "^u", "^99999999", "^0", "a^"] { + let _ = s.run("edit.replaceAll", &json!({"text": q, "with": "^&^c\\9^p^", "wildcards": false})); + } +} + #[test] fn replace_under_track_changes_skips_deleted_text() { // Replace marks the match deleted and leaves it in the text; the next diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 08d2d31..496cc35 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -15,6 +15,108 @@ use wordcraft_engine::Password; use crate::WordApp; use crate::theme::{Tokens, semibold}; +/// The Find and Replace dialog's search options beyond the first row (#543). +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FindExtra { + pub wildcards: bool, + pub prefix: bool, + pub suffix: bool, + pub ignore_punct: bool, + pub ignore_space: bool, + /// The search options are shown. + pub more: bool, + /// Special ▾ inserts into Replace with (it last had the focus), not Find what. + pub to_replace: bool, +} + +/// The Special ▾ menu: (name, code) for Find what, without and with wildcards. +const SPECIAL_FIND: &[(&str, &str)] = &[ + ("Paragraph Mark", "^p"), + ("Tab Character", "^t"), + ("Any Character", "^?"), + ("Any Digit", "^#"), + ("Any Letter", "^$"), + ("Caret Character", "^^"), + ("Section Character", "^%"), + ("Paragraph Character", "^v"), + ("Column Break", "^n"), + ("Em Dash", "^+"), + ("En Dash", "^="), + ("Graphic", "^g"), + ("Manual Line Break", "^l"), + ("Manual Page Break", "^m"), + ("Nonbreaking Hyphen", "^~"), + ("Nonbreaking Space", "^s"), + ("Optional Hyphen", "^-"), + ("Section Break", "^b"), + ("White Space", "^w"), +]; +const SPECIAL_FIND_WILD: &[(&str, &str)] = &[ + ("Any Character", "?"), + ("Character in Range", "[-]"), + ("Beginning of Word", "<"), + ("End of Word", ">"), + ("Expression", "()"), + ("Not", "[!]"), + ("Num Occurrences", "{,}"), + ("Previous 1 or More", "@"), + ("0 or More Characters", "*"), + ("Paragraph Mark", "^13"), + ("Tab Character", "^t"), + ("Caret Character", "^^"), + ("Section Character", "^%"), + ("Paragraph Character", "^v"), + ("Column Break", "^n"), + ("Em Dash", "^+"), + ("En Dash", "^="), + ("Graphic", "^g"), + ("Manual Line Break", "^l"), + ("Manual Page Break", "^m"), + ("Nonbreaking Hyphen", "^~"), + ("Nonbreaking Space", "^s"), + ("Optional Hyphen", "^-"), +]; +/// Replace with; with wildcards, "Find What Expression" (`\1`) comes first. +const SPECIAL_REPLACE: &[(&str, &str)] = &[ + ("Paragraph Mark", "^p"), + ("Tab Character", "^t"), + ("Caret Character", "^^"), + ("Section Character", "^%"), + ("Paragraph Character", "^v"), + ("Clipboard Contents", "^c"), + ("Column Break", "^n"), + ("Em Dash", "^+"), + ("En Dash", "^="), + ("Find What Text", "^&"), + ("Manual Line Break", "^l"), + ("Manual Page Break", "^m"), + ("Nonbreaking Hyphen", "^~"), + ("Nonbreaking Space", "^s"), + ("Optional Hyphen", "^-"), +]; + +/// The Special ▾ menu button; returns the code picked. +fn special_menu(ui: &mut Ui, extra: &FindExtra) -> Option<&'static str> { + let mut picked = None; + ui.menu_button(format!("{} ▾", tl!("Special")), |ui| { + let wild_replace: &[(&str, &str)] = &[("Find What Expression", "\\1")]; + let lists: [&[(&str, &str)]; 2] = match (extra.to_replace, extra.wildcards) { + (false, false) => [SPECIAL_FIND, &[]], + (false, true) => [SPECIAL_FIND_WILD, &[]], + (true, false) => [SPECIAL_REPLACE, &[]], + (true, true) => [wild_replace, SPECIAL_REPLACE], + }; + for (name, code) in lists.into_iter().flatten() { + if ui.button(format!("{} {code}", tl!(name))).clicked() { + picked = Some(*code); + ui.close(); + } + } + }); + picked +} + #[derive(Clone, Debug, Serialize)] #[serde(tag = "dialog", rename_all = "camelCase")] pub enum Dialog { @@ -84,6 +186,8 @@ pub enum Dialog { match_case: bool, whole_word: bool, regex: bool, + /// Search options under "Search Options" (#543). + extra: FindExtra, replace_mode: bool, message: String, }, @@ -869,6 +973,19 @@ impl Dialog { match_case: app.session.find.match_case, whole_word: app.session.find.whole_word, regex: app.session.find.regex, + extra: FindExtra { + wildcards: app.session.find.wildcards, + prefix: app.session.find.match_prefix, + suffix: app.session.find.match_suffix, + ignore_punct: app.session.find.ignore_punct, + ignore_space: app.session.find.ignore_space, + more: app.session.find.wildcards + || app.session.find.match_prefix + || app.session.find.match_suffix + || app.session.find.ignore_punct + || app.session.find.ignore_space, + to_replace: false, + }, replace_mode: name == "replace", message: String::new(), }, @@ -1462,7 +1579,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } Dialog::Tabs { form } => crate::dialogs_para::tabs(app, ui, form), Dialog::Borders { form } => crate::dialogs_para::borders(app, ui, form), - Dialog::Find { query, replace, match_case, whole_word, regex, replace_mode, message } => { + Dialog::Find { query, replace, match_case, whole_word, regex, extra, replace_mode, message } => { ui.horizontal(|ui| { if ui.selectable_label(!*replace_mode, tl!("Find")).clicked() { *replace_mode = false; @@ -1473,20 +1590,59 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); ui.horizontal(|ui| { ui.label(tl!("Find what:")); - ui.add(egui::TextEdit::singleline(query).desired_width(240.0)); + if ui.add(egui::TextEdit::singleline(query).desired_width(240.0)).gained_focus() { + extra.to_replace = false; + } }); if *replace_mode { ui.horizontal(|ui| { ui.label(tl!("Replace with:")); - ui.add(egui::TextEdit::singleline(replace).desired_width(228.0)); + if ui.add(egui::TextEdit::singleline(replace).desired_width(228.0)).gained_focus() { + extra.to_replace = true; + } + }); + } else { + extra.to_replace = false; + } + // Word greys out Match case and the word options with wildcards (they always match case). + let plain = !extra.wildcards; + ui.horizontal(|ui| { + ui.add_enabled(plain, egui::Checkbox::new(match_case, tl!("Match case"))); + ui.add_enabled(plain, egui::Checkbox::new(whole_word, tl!("Whole words"))); + ui.checkbox(&mut extra.wildcards, tl!("Use wildcards")); + }); + let arrow = if extra.more { "▾" } else { "▸" }; + if ui.selectable_label(extra.more, format!("{arrow} {}", tl!("Search Options"))).clicked() { + extra.more = !extra.more; + } + if extra.more { + ui.horizontal(|ui| { + ui.add_enabled(plain, egui::Checkbox::new(&mut extra.prefix, tl!("Match prefix"))); + ui.add_enabled(plain, egui::Checkbox::new(&mut extra.suffix, tl!("Match suffix"))); + }); + ui.horizontal(|ui| { + ui.add_enabled(plain, egui::Checkbox::new(&mut extra.ignore_punct, tl!("Ignore punctuation characters"))); + ui.add_enabled(plain, egui::Checkbox::new(&mut extra.ignore_space, tl!("Ignore white-space characters"))); + }); + ui.horizontal(|ui| { + ui.checkbox(regex, tl!("Regular expressions")); + if let Some(code) = special_menu(ui, extra) { + if extra.to_replace { replace.push_str(code) } else { query.push_str(code) } + } }); } - ui.horizontal(|ui| { - ui.checkbox(match_case, tl!("Match case")); - ui.checkbox(whole_word, tl!("Whole words")); - ui.checkbox(regex, tl!("Wildcards (regex)")); + let opts = json!({ + "text": query, + "with": replace, + "matchCase": *match_case, + "wholeWord": *whole_word, + "regex": *regex, + "wildcards": extra.wildcards, + "matchPrefix": extra.prefix, + "matchSuffix": extra.suffix, + "ignorePunct": extra.ignore_punct, + "ignoreSpace": extra.ignore_space, }); - let opts = json!({"text": query, "with": replace, "matchCase": *match_case, "wholeWord": *whole_word, "regex": *regex}); if !*replace_mode { ui.horizontal(|ui| { let lit = app.session.find.highlight; diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e7f5b29..9488459 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -949,7 +949,6 @@ Wide Ancho Widow/Orphan Control Control de líneas viudas y huérfanas Widow/Orphan control Control de líneas viudas y huérfanas - Wildcards (regex) Comodines (expresión regular) Window Ventana Word Count Recuento de palabras Word Left Palabra a la izquierda @@ -1853,6 +1852,37 @@ Rewind Rebobinar Forward Avanzar Stroke {n} of {total} Trazo {n} de {total} + Use wildcards Usar caracteres comodín + Special Especial + Match prefix Coincidir prefijo + Match suffix Coincidir sufijo + Ignore punctuation characters Omitir signos de puntuación + Ignore white-space characters Omitir espacios en blanco + Regular expressions Expresiones regulares + Search Options Opciones de búsqueda + Tab Character Carácter de tabulación + Any Character Cualquier carácter + Any Digit Cualquier dígito + Any Letter Cualquier letra + Caret Character Acento circunflejo + Section Character Símbolo de sección + Paragraph Character Símbolo de párrafo + Graphic Gráfico + Manual Line Break Salto de línea manual + Manual Page Break Salto de página manual + Section Break Salto de sección + White Space Espacio en blanco + Clipboard Contents Contenido del portapapeles + Find What Text Texto buscado + Find What Expression Expresión buscada + Character in Range Carácter del intervalo + Beginning of Word Principio de palabra + End of Word Final de palabra + Expression Expresión + Not No + Num Occurrences Número de repeticiones + Previous 1 or More Anterior 1 o más veces + 0 or More Characters 0 o más caracteres Move with Text Mover con el texto Fix Position on Page Fijar posición en la página diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 453bac3..81428c6 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -833,7 +833,6 @@ Wide Lai Widow/Orphan Control Üksikridade vältimine Widow/Orphan control Üksikridade vältimine - Wildcards (regex) Metamärgid (regulaaravaldis) Window Aken Word Count Sõnade arv Word Left Sõna vasakule @@ -1816,6 +1815,37 @@ Rewind Keri algusesse Forward Edasi Stroke {n} of {total} Joon {n}/{total} + Use wildcards Kasuta metamärke + Special Erimärgid + Match prefix Sobita eesliide + Match suffix Sobita järelliide + Ignore punctuation characters Eira kirjavahemärke + Ignore white-space characters Eira tühikumärke + Regular expressions Regulaaravaldised + Search Options Otsingusuvandid + Tab Character Tabeldusmärk + Any Character Suvaline märk + Any Digit Suvaline number + Any Letter Suvaline täht + Caret Character Katusmärk + Section Character Paragrahvi märk + Paragraph Character Lõigu sümbol + Graphic Pilt + Manual Line Break Käsitsi reapiir + Manual Page Break Käsitsi lehepiir + Section Break Jaotisepiir + White Space Tühimärk + Clipboard Contents Lõikelaua sisu + Find What Text Otsitav tekst + Find What Expression Otsitav avaldis + Character in Range Märk vahemikus + Beginning of Word Sõna algus + End of Word Sõna lõpp + Expression Avaldis + Not Mitte + Num Occurrences Korduste arv + Previous 1 or More Eelmine 1 või rohkem korda + 0 or More Characters 0 või rohkem märki Move with Text Liigu tekstiga Fix Position on Page Fikseeri asukoht lehel diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2798764..527b533 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -925,7 +925,6 @@ Wide 広い Widow/Orphan Control 改ページ時 1 行残して段落を区切らない Widow/Orphan control 改ページ時 1 行残して段落を区切らない - Wildcards (regex) ワイルドカード(正規表現) Window ウィンドウ Word Count 文字カウント Word document (*.docx) Word 文書 (*.docx) @@ -1837,6 +1836,37 @@ Rewind 巻き戻し Forward 早送り Stroke {n} of {total} ストローク {n}/{total} + Use wildcards ワイルドカードを使う + Special 特殊文字 + Match prefix 語頭が一致する + Match suffix 語尾が一致する + Ignore punctuation characters 句読点を無視する + Ignore white-space characters 空白文字を無視する + Regular expressions 正規表現 + Search Options 検索オプション + Tab Character タブ文字 + Any Character 任意の 1 文字 + Any Digit 任意の数字 + Any Letter 任意の英字 + Caret Character キャレット記号 + Section Character セクション記号 + Paragraph Character 段落記号文字 + Graphic 図 + Manual Line Break 手動の改行 + Manual Page Break 手動の改ページ + Section Break セクション区切り + White Space 空白 + Clipboard Contents クリップボードの内容 + Find What Text 検索した文字列 + Find What Expression 検索した式 + Character in Range 範囲内の文字 + Beginning of Word 単語の先頭 + End of Word 単語の末尾 + Expression 式 + Not 否定 + Num Occurrences 繰り返し回数 + Previous 1 or More 直前の 1 回以上の繰り返し + 0 or More Characters 0 文字以上の文字列 Move with Text 文字列と一緒に移動する Fix Position on Page ページ上で位置を固定する diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6e87f82..5401d6f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -925,7 +925,6 @@ Wide Ampla Widow/Orphan Control Controle de viúvas e órfãs Widow/Orphan control Controle de viúvas e órfãs - Wildcards (regex) Curingas (regex) Window Janela Word Count Contagem de palavras Word document (*.docx) Documento Word (*.docx) @@ -1849,6 +1848,37 @@ Rewind Retroceder Forward Avançar Stroke {n} of {total} Traço {n} de {total} + Use wildcards Usar caracteres curinga + Special Especial + Match prefix Coincidir prefixo + Match suffix Coincidir sufixo + Ignore punctuation characters Ignorar sinais de pontuação + Ignore white-space characters Ignorar espaços em branco + Regular expressions Expressões regulares + Search Options Opções de pesquisa + Tab Character Caractere de tabulação + Any Character Qualquer caractere + Any Digit Qualquer dígito + Any Letter Qualquer letra + Caret Character Acento circunflexo + Section Character Símbolo de seção + Paragraph Character Símbolo de parágrafo + Graphic Gráfico + Manual Line Break Quebra de linha manual + Manual Page Break Quebra de página manual + Section Break Quebra de seção + White Space Espaço em branco + Clipboard Contents Conteúdo da área de transferência + Find What Text Texto localizado + Find What Expression Expressão localizada + Character in Range Caractere no intervalo + Beginning of Word Início da palavra + End of Word Fim da palavra + Expression Expressão + Not Não + Num Occurrences Número de ocorrências + Previous 1 or More Anterior 1 ou mais vezes + 0 or More Characters 0 ou mais caracteres Move with Text Mover com o texto Fix Position on Page Fixar posição na página diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 95eb068..cc8a840 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -804,7 +804,6 @@ preview Title Пример Wide Широкие Widow/Orphan Control Запрет висячих строк Widow/Orphan control Запрет висячих строк - Wildcards (regex) Подстановочные знаки (regex) Window Окно Word Count Число слов Word document (*.docx) Документ Word (*.docx) @@ -2113,6 +2112,38 @@ preview Title Пример Apply Применить Convert Notes Преобразовать сноски +# Find and Replace special characters and wildcards (#543). + Use wildcards Подстановочные знаки + Special Специальный + Match prefix Начало слова + Match suffix Конец слова + Ignore punctuation characters Не учитывать знаки препинания + Ignore white-space characters Не учитывать пробелы + Regular expressions Регулярные выражения + Search Options Параметры поиска + Tab Character Знак табуляции + Any Character Любой знак + Any Digit Любая цифра + Any Letter Любая буква + Caret Character Знак вставки (^) + Section Character Знак раздела + Paragraph Character Знак абзаца + Graphic Графический объект + Manual Line Break Разрыв строки + Manual Page Break Разрыв страницы + Section Break Разрыв раздела + White Space Пробельный знак + Clipboard Contents Содержимое буфера обмена + Find What Text Текст для поиска + Find What Expression Найденное выражение + Character in Range Знак из диапазона + Beginning of Word Начало слова + End of Word Конец слова + Expression Выражение + Not Не + Num Occurrences Число вхождений + Previous 1 or More Предыдущий знак 1 или более раз + 0 or More Characters 0 или более знаков # Insert SmartArt (#497). Add Shape Добавить фигуру Change Colors Изменить цвета diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index f35c9b6..9eab4d3 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -985,7 +985,6 @@ Wide Široko Widow/Orphan Control Kontrola usamljenih redova Widow/Orphan control Kontrola usamljenih redova - Wildcards (regex) Džoker znaci (regex) Window Prozor Word Count Broj reči Word document (*.docx) Word dokument (*.docx) @@ -1847,6 +1846,37 @@ Rewind Na početak Forward Napred Stroke {n} of {total} Potez {n} od {total} + Use wildcards Koristi džoker znake + Special Posebno + Match prefix Podudari prefiks + Match suffix Podudari sufiks + Ignore punctuation characters Zanemari znake interpunkcije + Ignore white-space characters Zanemari beline + Regular expressions Regularni izrazi + Search Options Opcije pretrage + Tab Character Znak tabulatora + Any Character Bilo koji znak + Any Digit Bilo koja cifra + Any Letter Bilo koje slovo + Caret Character Znak kapice + Section Character Znak odeljka + Paragraph Character Simbol pasusa + Graphic Grafika + Manual Line Break Ručni prekid reda + Manual Page Break Ručni prekid strane + Section Break Prekid odeljka + White Space Belina + Clipboard Contents Sadržaj ostave + Find What Text Pronađeni tekst + Find What Expression Pronađeni izraz + Character in Range Znak u opsegu + Beginning of Word Početak reči + End of Word Kraj reči + Expression Izraz + Not Nije + Num Occurrences Broj ponavljanja + Previous 1 or More Prethodno 1 ili više puta + 0 or More Characters 0 ili više znakova Move with Text Pomeraj sa tekstom Fix Position on Page Fiksiraj položaj na stranici diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f9dc8bc..6db3d07 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -989,7 +989,6 @@ Wide Широко Widow/Orphan Control Контрола усамљених редова Widow/Orphan control Контрола усамљених редова - Wildcards (regex) Џокер знаци (regex) Window Прозор Word Count Број речи Word document (*.docx) Word документ (*.docx) @@ -1851,6 +1850,37 @@ Rewind На почетак Forward Напред Stroke {n} of {total} Потез {n} од {total} + Use wildcards Користи џокер знаке + Special Посебно + Match prefix Подудари префикс + Match suffix Подудари суфикс + Ignore punctuation characters Занемари знаке интерпункције + Ignore white-space characters Занемари белине + Regular expressions Регуларни изрази + Search Options Опције претраге + Tab Character Знак табулатора + Any Character Било који знак + Any Digit Било која цифра + Any Letter Било које слово + Caret Character Знак капице + Section Character Знак одељка + Paragraph Character Симбол пасуса + Graphic Графика + Manual Line Break Ручни прекид реда + Manual Page Break Ручни прекид стране + Section Break Прекид одељка + White Space Белина + Clipboard Contents Садржај оставе + Find What Text Пронађени текст + Find What Expression Пронађени израз + Character in Range Знак у опсегу + Beginning of Word Почетак речи + End of Word Крај речи + Expression Израз + Not Није + Num Occurrences Број понављања + Previous 1 or More Претходно 1 или више пута + 0 or More Characters 0 или више знакова Move with Text Померај са текстом Fix Position on Page Фиксирај положај на страници diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index da768e2..30f9743 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -798,7 +798,6 @@ Wide Geniş Widow/Orphan Control Tek Kalan Satır Denetimi Widow/Orphan control Tek kalan satır denetimi - Wildcards (regex) Joker karakterler (regex) Window Pencere Word Count Sözcük Sayısı Word Left Önceki Sözcük @@ -1735,6 +1734,38 @@ Apply Uygula Convert Notes Notları Dönüştür +# Find and Replace special characters and wildcards (#543). + Use wildcards Joker karakter kullan + Special Özel + Match prefix Ön eke uy + Match suffix Son eke uy + Ignore punctuation characters Noktalama karakterlerini yoksay + Ignore white-space characters Boşluk karakterlerini yoksay + Regular expressions Düzenli ifadeler + Search Options Arama Seçenekleri + Tab Character Sekme Karakteri + Any Character Herhangi Bir Karakter + Any Digit Herhangi Bir Rakam + Any Letter Herhangi Bir Harf + Caret Character Şapka Karakteri + Section Character Bölüm Karakteri + Paragraph Character Paragraf Karakteri + Graphic Grafik + Manual Line Break El ile Satır Sonu + Manual Page Break El ile Sayfa Sonu + Section Break Bölüm Sonu + White Space Boşluk + Clipboard Contents Pano İçeriği + Find What Text Aranan Metin + Find What Expression Aranan İfade + Character in Range Aralıktaki Karakter + Beginning of Word Sözcük Başı + End of Word Sözcük Sonu + Expression İfade + Not Değil + Num Occurrences Tekrar Sayısı + Previous 1 or More Öncekinin 1 veya Daha Fazlası + 0 or More Characters 0 veya Daha Fazla Karakter # Insert SmartArt (#497). Add Shape Şekil Ekle Change Colors Renkleri Değiştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ad1d8e9..e0494fb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -951,7 +951,6 @@ Wide Широкі Widow/Orphan Control Не залишати самотні рядки Widow/Orphan control Не залишати самотні рядки - Wildcards (regex) Шаблони (регулярні вирази) Window Вікно Word Count Кількість слів Word document (*.docx) Документ Word (*.docx) @@ -1849,6 +1848,37 @@ Rewind На початок Forward Вперед Stroke {n} of {total} Штрих {n} з {total} + Use wildcards Використовувати шаблони + Special Спеціальні + Match prefix Збіг префікса + Match suffix Збіг суфікса + Ignore punctuation characters Ігнорувати розділові знаки + Ignore white-space characters Ігнорувати пробільні символи + Regular expressions Регулярні вирази + Search Options Параметри пошуку + Tab Character Символ табуляції + Any Character Будь-який символ + Any Digit Будь-яка цифра + Any Letter Будь-яка літера + Caret Character Символ каретки + Section Character Знак параграфа + Paragraph Character Символ абзацу + Graphic Графіка + Manual Line Break Примусовий розрив рядка + Manual Page Break Примусовий розрив сторінки + Section Break Розрив розділу + White Space Пробіл + Clipboard Contents Вміст буфера обміну + Find What Text Знайдений текст + Find What Expression Знайдений вираз + Character in Range Символ із діапазону + Beginning of Word Початок слова + End of Word Кінець слова + Expression Вираз + Not Не + Num Occurrences Кількість повторень + Previous 1 or More Попередній 1 або більше разів + 0 or More Characters 0 або більше символів Move with Text Переміщувати з текстом Fix Position on Page Зафіксувати положення на сторінці diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bd465bd..0bce8ce 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -937,7 +937,6 @@ Wide 宽 Widow/Orphan Control 孤行控制 Widow/Orphan control 孤行控制 - Wildcards (regex) 通配符(正则表达式) Window 窗口 Word Count 字数统计 Word document (*.docx) Word 文档 (*.docx) @@ -1849,6 +1848,37 @@ Rewind 倒回 Forward 前进 Stroke {n} of {total} 第 {n} 笔,共 {total} 笔 + Use wildcards 使用通配符 + Special 特殊字符 + Match prefix 匹配前缀 + Match suffix 匹配后缀 + Ignore punctuation characters 忽略标点符号 + Ignore white-space characters 忽略空白字符 + Regular expressions 正则表达式 + Search Options 搜索选项 + Tab Character 制表符字符 + Any Character 任意字符 + Any Digit 任意数字 + Any Letter 任意字母 + Caret Character 脱字号 + Section Character 章节号 + Paragraph Character 段落符号 + Graphic 图形 + Manual Line Break 手动换行符 + Manual Page Break 手动分页符 + Section Break 分节符 + White Space 空白 + Clipboard Contents 剪贴板内容 + Find What Text 查找到的文本 + Find What Expression 查找到的表达式 + Character in Range 范围内的字符 + Beginning of Word 单词开头 + End of Word 单词结尾 + Expression 表达式 + Not 非 + Num Occurrences 出现次数 + Previous 1 or More 前一项 1 次或多次 + 0 or More Characters 0 个或多个字符 Move with Text 随文字移动 Fix Position on Page 在页面上的位置固定 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0b24232..37ba1e5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -926,7 +926,6 @@ Wide 寬 Widow/Orphan Control 遺留字串控制 Widow/Orphan control 遺留字串控制 - Wildcards (regex) 萬用字元(規則運算式) Window 視窗 Word Count 字數統計 Word document (*.docx) Word 文件 (*.docx) @@ -1838,6 +1837,37 @@ Rewind 倒轉 Forward 快轉 Stroke {n} of {total} 第 {n} 筆,共 {total} 筆 + Use wildcards 使用萬用字元 + Special 特殊字元 + Match prefix 比對字首 + Match suffix 比對字尾 + Ignore punctuation characters 忽略標點符號 + Ignore white-space characters 忽略空白字元 + Regular expressions 規則運算式 + Search Options 搜尋選項 + Tab Character 定位字元符號 + Any Character 任意字元 + Any Digit 任意數字 + Any Letter 任意字母 + Caret Character 插入號 + Section Character 章節符號 + Paragraph Character 段落符號 + Graphic 圖形 + Manual Line Break 手動換行 + Manual Page Break 手動分頁 + Section Break 分節符號 + White Space 空白 + Clipboard Contents 剪貼簿內容 + Find What Text 找到的文字 + Find What Expression 找到的運算式 + Character in Range 範圍內的字元 + Beginning of Word 單字開頭 + End of Word 單字結尾 + Expression 運算式 + Not 非 + Num Occurrences 出現次數 + Previous 1 or More 前一項 1 次以上 + 0 or More Characters 0 個以上字元 Move with Text 隨文字移動 Fix Position on Page 固定於頁面上的位置 diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1828956..258cb1a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Outlining tab and outline keys, #493; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Find and Replace special characters and wildcards, #543; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -37,6 +37,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Find and Replace: Special ▾ menu (^p, ^t, ^?, ^#, ^$, ^w, ^&, ^c … ; a shorter wildcard list), Use wildcards (`?` `*` `[a-z]` `[!x]` `{n,m}` `@` `<` `>` `( )`, `\1` in Replace with), Match prefix/suffix, Ignore punctuation and white space; `^p` matches and splits paragraphs, Replace All is one undo step (#543) | | 2026-10-11 | trivial | Update Style to Match Selection takes how the selection looks (its paragraph and character styles as well as direct formatting) and applies the style to it; Home › Styles gallery tiles get a right-click menu with Update to Match Selection and Modify… (#555) | | 2026-10-11 | trivial | Outlining tab in Outline view (level box, promote/demote, move, expand/collapse, Show Level, Show Text Formatting, Show First Line Only, Close); outline symbols select a heading with its content on click and expand/collapse on double-click; Alt+Shift+Plus/Minus, Alt+Shift+1–9, Alt+Shift+A (#493) | | 2026-10-11 | trivial | File › Options as panes (General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents), every setting wired; `file.options` headless (#485) |