- 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:
parent
850fa0576a
commit
7484a6bd11
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 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),
|
||||
}
|
||||
}
|
||||
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())),
|
||||
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;
|
||||
}
|
||||
live.push_str(t);
|
||||
if r.start != expect {
|
||||
cuts.push(hay.len());
|
||||
}
|
||||
for (at, raw, n) in spans {
|
||||
for m in re.find_iter(Input::new(&live).range(at..at + n)) {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue