Find and Replace: special characters (^p, ^t, …) and wildcards (#543) (#553)

- 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-12 03:15:31 +02:00 committed by GitHub
parent 850fa0576a
commit 7484a6bd11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1169 additions and 60 deletions

View File

@ -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) |

View File

@ -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<CommandSpec> {
.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<String>,
}
/// 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<Vec<(Pos, Pos)>, 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<Vec<Found>, 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<std::ops::Range<usize>> = 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<Seg> = Vec::new();
// Where the live text breaks (tracked-deleted text, or a paragraph edge when not spanning).
let mut cuts: Vec<usize> = 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<Pos> {
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() {

View File

@ -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<char> {
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<char> {
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<Compiled, String> {
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<char> = query.chars().collect();
let mut atoms: Vec<String> = 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<char> = 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<String, String> {
let bad = || format!("{{{body}}} is not a valid count");
let num = |s: &str| -> Result<Option<u32>, 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<String, String> {
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<Piece> {
if o.literal || o.regex {
return vec![Piece::Lit(with.to_string())];
}
let chars: Vec<char> = with.chars().collect();
let mut out = Vec::new();
let mut cur = String::new();
let flush = |cur: &mut String, out: &mut Vec<Piece>| {
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<String>, 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
}

View File

@ -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;

View File

@ -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)>,

View File

@ -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": "<cat", "wildcards": true}))["count"], 2);
assert_eq!(run(&mut s, "edit.find", json!({"text": "cat>", "wildcards": true}))["count"], 2);
assert_eq!(run(&mut s, "edit.find", json!({"text": "<c*t>", "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

View File

@ -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;

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -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 ページ上で位置を固定する

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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 Изменить цвета

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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 Фиксирај положај на страници

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -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

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -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 Зафіксувати положення на сторінці

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -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 在页面上的位置固定

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -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 固定於頁面上的位置

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -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) |