mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 00:40:33 -06:00
M0: reconcile current main and classify double-space rewrites for egui 0.37
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
//! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing, Rgb, TextColor};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
|
||||
use wordcraft_doc::{Watermark, styles};
|
||||
|
||||
use super::sel_result;
|
||||
@@ -67,6 +67,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"name": "default|basic|lines|shaded|casual|centered|minimalist|title"}"#),
|
||||
CommandSpec::new("design.paragraphSpacing", "Paragraph Spacing", "Design › Document Formatting", para_spacing)
|
||||
.params(r#"{"value": "default|none|compact|tight|open|relaxed|double"}"#),
|
||||
CommandSpec::new("design.scaleFontSizes", "Scale Font Sizes", "Design › Document Formatting", scale_font_sizes)
|
||||
.params(r#"{"delta": pt (-1638..1638, not 0) | "percent": number (-99..1000, not 0; 10 = +10%)} (exactly one)"#),
|
||||
CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark)
|
||||
.params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#),
|
||||
CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| {
|
||||
@@ -169,11 +171,19 @@ fn style_set(s: &mut Session, v: &Value) -> CmdResult {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Keep custom styles and fonts.
|
||||
let customs: Vec<_> = s.doc.styles.styles.iter().filter(|x| !x.builtin).cloned().collect();
|
||||
fresh.styles.extend(customs);
|
||||
fresh.default_chr.font = s.doc.styles.default_chr.font.clone();
|
||||
s.doc.styles = fresh;
|
||||
// Overlay the set onto the document's sheet: every other style, the latent styles and the
|
||||
// document defaults stay as they are (no set defines its own defaults).
|
||||
for mut st in fresh.styles {
|
||||
match s.doc.styles.styles.iter_mut().find(|x| x.id == st.id) {
|
||||
Some(old) => {
|
||||
st.default = old.default;
|
||||
st.locked = old.locked;
|
||||
st.builtin = old.builtin;
|
||||
*old = st;
|
||||
}
|
||||
None => s.doc.styles.styles.push(st),
|
||||
}
|
||||
}
|
||||
let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone());
|
||||
set_fonts(s, &h, &b);
|
||||
set_accent(s, accent);
|
||||
@@ -196,6 +206,45 @@ fn para_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Grow or shrink every style's font size at once. Only explicit style sizes change (including
|
||||
/// table style parts); styles that inherit one (via `based_on` or the defaults) follow, and runs
|
||||
/// keep their own direct formatting.
|
||||
fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (delta, percent) = (p::f32(v, "delta"), p::f32(v, "percent"));
|
||||
// A field that is present but not a number is an error, not "absent".
|
||||
if (v.get("delta").is_some() && delta.is_none()) || (v.get("percent").is_some() && percent.is_none()) {
|
||||
return Err(CmdError::Params("`delta` and `percent` must be numbers".into()));
|
||||
}
|
||||
if delta.is_some() == percent.is_some() {
|
||||
return Err(CmdError::Params("give exactly one of `delta` (points) or `percent`".into()));
|
||||
}
|
||||
if delta.is_some_and(|d| !(-1638.0..=1638.0).contains(&d) || d == 0.0) || percent.is_some_and(|x| !(-99.0..=1000.0).contains(&x) || x == 0.0) {
|
||||
return Err(CmdError::Params("`delta` must be non-zero within ±1638 pt and `percent` non-zero within -99..1000".into()));
|
||||
}
|
||||
let factor = 1.0 + percent.unwrap_or(0.0) / 100.0;
|
||||
let scale = |size: f32| ((size * factor + delta.unwrap_or(0.0)).clamp(1.0, 1638.0) * 2.0).round() / 2.0;
|
||||
let scale_chr = |c: &mut CharProps| {
|
||||
c.size = c.size.map(scale);
|
||||
c.size_cs = c.size_cs.map(scale);
|
||||
};
|
||||
let st = &mut s.doc.styles;
|
||||
// An unset document default resolves to the fallback size; make it explicit so inheriting styles follow.
|
||||
st.default_chr.size = Some(scale(st.default_chr.size.unwrap_or(wordcraft_doc::props::DEFAULT_FONT_SIZE)));
|
||||
st.default_chr.size_cs = st.default_chr.size_cs.map(scale);
|
||||
for style in &mut st.styles {
|
||||
scale_chr(&mut style.chr);
|
||||
if let Some(t) = style.table.as_mut() {
|
||||
for c in [&mut t.header_chr, &mut t.band_chr, &mut t.first_col_chr, &mut t.last_col_chr, &mut t.total_chr, &mut t.col_band_chr] {
|
||||
scale_chr(c);
|
||||
}
|
||||
for part in t.regions.values_mut() {
|
||||
scale_chr(&mut part.chr);
|
||||
}
|
||||
}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn watermark(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::bool(v, "remove").unwrap_or(false) {
|
||||
s.doc.settings.watermark = None;
|
||||
|
||||
@@ -142,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"tab"?: "find|replace|goto" (default replace)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all)
|
||||
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#),
|
||||
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards; with regex also $1 ${n} ${name} $0 $$ \\1 \\n (paragraph mark) \\t), …options of edit.find}"#),
|
||||
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto)
|
||||
.key("Mod+Alt+G / F5")
|
||||
.params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string (selects its text), "paragraph"?: n}; none opens Find and Replace on its Go To tab"#)
|
||||
@@ -459,7 +459,10 @@ pub(crate) fn search_full(s: &Session, story: StoryRef, with_rep: bool) -> Resul
|
||||
.configure(meta::Config::new().nfa_size_limit(Some(1 << 20)))
|
||||
.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 pieces = with_rep.then(|| {
|
||||
let index = |name: &str| re.group_info().to_index(regex_automata::PatternID::ZERO, name);
|
||||
find_pattern::resolve_names(find_pattern::parse_replace(&f.replace, &opts), &index)
|
||||
});
|
||||
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();
|
||||
|
||||
@@ -345,15 +345,20 @@ pub enum Piece {
|
||||
Lit(String),
|
||||
/// `^&`: the text found.
|
||||
Found,
|
||||
/// `\n` with wildcards: group `n`.
|
||||
/// `\n` with wildcards, or `$n` / `${n}` / `\n` with a regex: group `n` (0 is the match).
|
||||
Group(usize),
|
||||
/// `${name}` / `$name` with a regex: a named group, resolved to a [`Piece::Group`] once the
|
||||
/// pattern is built ([`resolve_names`]); one left unresolved expands to nothing.
|
||||
Named(String),
|
||||
/// `^c`: the clipboard.
|
||||
Clip,
|
||||
}
|
||||
|
||||
/// Read Replace with text: `^` codes (unless `literal`), and `\1`–`\9` with wildcards.
|
||||
/// Read Replace with text: `^` codes (unless `literal`), `\1`–`\9` with wildcards, and with a
|
||||
/// regex also `$n`, `${n}`, `${name}`, `$name`, `$$`, `\n` (paragraph mark), `\t` and `\\`.
|
||||
pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
if o.literal || o.regex {
|
||||
// With a regex, `special: false` turns off only the `^` codes; group references still expand.
|
||||
if o.literal && !o.regex {
|
||||
return vec![Piece::Lit(with.to_string())];
|
||||
}
|
||||
let chars: Vec<char> = with.chars().collect();
|
||||
@@ -367,6 +372,22 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
i += 1;
|
||||
if o.regex && (c == '$' || c == '\\') {
|
||||
match regex_ref(&chars, i, c) {
|
||||
Some((Ref::Char(ch), used)) => {
|
||||
cur.push(ch);
|
||||
i += used;
|
||||
}
|
||||
Some((Ref::Group(pc), used)) => {
|
||||
flush(&mut cur, &mut out);
|
||||
out.extend(pc);
|
||||
i += used;
|
||||
}
|
||||
// Not a reference or an escape: the character as written.
|
||||
None => cur.push(c),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '\\' && o.wildcards {
|
||||
match chars.get(i) {
|
||||
Some(d) if d.is_ascii_digit() && *d != '0' => {
|
||||
@@ -384,6 +405,7 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
}
|
||||
}
|
||||
if c == '^'
|
||||
&& !o.literal
|
||||
&& let Some(&n) = chars.get(i)
|
||||
{
|
||||
if n == '&' || n == 'c' {
|
||||
@@ -409,6 +431,67 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
out
|
||||
}
|
||||
|
||||
/// What a `$` or `\` starts in regex Replace with text.
|
||||
enum Ref {
|
||||
/// An escape: `$$`, `\n`, `\t`, `\\`.
|
||||
Char(char),
|
||||
/// A group reference; `None` for a number too large to be a group (expands to nothing).
|
||||
Group(Option<Piece>),
|
||||
}
|
||||
|
||||
/// A group named by its number or name, as the `regex` crate reads it.
|
||||
fn group_ref(name: &str) -> Option<Piece> {
|
||||
if name.bytes().all(|b| b.is_ascii_digit()) { name.parse().ok().map(Piece::Group) } else { Some(Piece::Named(name.to_string())) }
|
||||
}
|
||||
|
||||
/// Read a regex reference or escape after `c` (`$` or `\`) at `chars[i..]`: what it is and how
|
||||
/// many characters it uses after `c`, or `None` when `c` is a plain character.
|
||||
fn regex_ref(chars: &[char], i: usize, c: char) -> Option<(Ref, usize)> {
|
||||
let next = *chars.get(i)?;
|
||||
if c == '\\' {
|
||||
return match next {
|
||||
'n' => Some((Ref::Char(PARA_MARK), 1)),
|
||||
't' => Some((Ref::Char('\t'), 1)),
|
||||
// `\$` and `\^` keep a `$` or `^` as typed (no group, no `^` code).
|
||||
'\\' | '$' | '^' => Some((Ref::Char(next), 1)),
|
||||
'1'..='9' => Some((Ref::Group(next.to_digit(10).map(|d| Piece::Group(d as usize))), 1)),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if next == '$' {
|
||||
return Some((Ref::Char('$'), 1));
|
||||
}
|
||||
let rest = chars.get(i..).unwrap_or_default();
|
||||
if next == '{' {
|
||||
// `${…}`: everything up to the closing brace; unclosed or empty, the `$` is literal.
|
||||
let close = rest.iter().position(|&ch| ch == '}')?;
|
||||
let name: String = rest.get(1..close)?.iter().collect();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some((Ref::Group(group_ref(&name)), close + 1));
|
||||
}
|
||||
// `$name`: the longest run of letters, digits and `_`.
|
||||
let len = rest.iter().take_while(|ch| ch.is_ascii_alphanumeric() || **ch == '_').count();
|
||||
if len == 0 {
|
||||
return None;
|
||||
}
|
||||
let name: String = rest.get(..len)?.iter().collect();
|
||||
Some((Ref::Group(group_ref(&name)), len))
|
||||
}
|
||||
|
||||
/// Turn named group references into numbered ones with `index` (a name's group number);
|
||||
/// an unknown name is dropped, so it expands to nothing.
|
||||
pub fn resolve_names(pieces: Vec<Piece>, index: &dyn Fn(&str) -> Option<usize>) -> Vec<Piece> {
|
||||
pieces
|
||||
.into_iter()
|
||||
.filter_map(|p| match p {
|
||||
Piece::Named(n) => index(&n).map(Piece::Group),
|
||||
p => Some(p),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Searched text as it is inserted again: marks become paragraph breaks.
|
||||
fn as_inserted(s: &str) -> String {
|
||||
s.replace(SECT_MARK, "\r")
|
||||
@@ -423,6 +506,7 @@ pub fn expand(pieces: &[Piece], found: &str, group: &dyn Fn(usize) -> Option<Str
|
||||
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")),
|
||||
Piece::Named(_) => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -97,13 +97,14 @@ fn typing_options(s: &mut Session, text: &str) {
|
||||
}
|
||||
|
||||
/// Inserts text with the typing options and smart quotes, records the IME outcome, and for a
|
||||
/// single trigger character runs list, AutoCorrect and dash AutoFormat as a separate undo step
|
||||
/// single trigger character runs list, AutoCorrect, dash and double-space AutoFormat as a separate undo step
|
||||
/// when they rewrite the paragraph.
|
||||
fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let text = p::req_str(v, "text")?;
|
||||
let raw = p::bool(v, "raw").unwrap_or(false);
|
||||
typing_options(s, text);
|
||||
let smart_quote = if raw { None } else { autoformat(s, text) };
|
||||
let was_collapsed = s.sel.is_collapsed();
|
||||
let effect = type_text_effect(s, smart_quote.as_deref().unwrap_or(text))?;
|
||||
let mut outcome = crate::ImeEditOutcome {
|
||||
classified: true,
|
||||
@@ -130,9 +131,12 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
&& s.prefs.autocorrect.enabled
|
||||
&& s.prefs.autocorrect.dashes
|
||||
&& dash_autoformat(s).inspect_err(|_| s.autocap = None)?;
|
||||
if text == " " && !listed && was_collapsed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.double_space_period {
|
||||
double_space_period(s)?;
|
||||
}
|
||||
// Compare the paragraph with the one exactly as typed. This is the actual effect of
|
||||
// AutoCorrect (including formatting-only superscripts and links), not an undo label that
|
||||
// may have been left by an earlier step.
|
||||
// may have been left by an earlier step. It also covers the double-space period.
|
||||
let rewritten = typed.as_ref().is_some_and(|(para, _)| s.doc.para_at(&f) != Some(para));
|
||||
if listed || dashed || rewritten {
|
||||
outcome.content_changed = true;
|
||||
@@ -210,6 +214,27 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// "word" + two spaces → "word. ": the second Space turns the first into a period, after a
|
||||
/// letter, a digit or a closing bracket or quote; not after other punctuation, at the start of a
|
||||
/// paragraph, after a tab, or for a third space (the text before them ends in a period by then).
|
||||
fn double_space_period(s: &mut Session) -> Result<(), CmdError> {
|
||||
let f = s.sel.focus.clone();
|
||||
let Some(para) = s.doc.para_at(&f) else { return Ok(()) };
|
||||
let Some(before) = para.text.get(..f.off) else { return Ok(()) };
|
||||
let Some(word) = before.strip_suffix(" ") else { return Ok(()) };
|
||||
if !word.chars().next_back().is_some_and(|c| c.is_alphanumeric() || ")]}\"'\u{201D}\u{2019}\u{00BB}\u{203A}".contains(c)) {
|
||||
return Ok(());
|
||||
}
|
||||
// The period takes the formatting of the space it replaces.
|
||||
let at = word.len();
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
let props = p.props_at(at + 1).clone().without_symbol();
|
||||
p.delete(at, at + 1)?;
|
||||
p.insert_text(at, ".", &props)?;
|
||||
s.sel = Selection::caret(f);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// "word -- word" → en dash, "word--word" → em dash (on the space after).
|
||||
fn dash_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
||||
let f = s.sel.focus.clone();
|
||||
|
||||
@@ -114,6 +114,9 @@ pub struct AutoCorrectPrefs {
|
||||
/// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs).
|
||||
/// Off by default.
|
||||
pub heading_styles: bool,
|
||||
/// A second Space after a word, number or closing bracket or quote turns the first into a
|
||||
/// period (macOS's "Add period with double-space"); off by default.
|
||||
pub double_space_period: bool,
|
||||
/// The user's entries: added, or replacing a built-in one.
|
||||
pub entries: Vec<(String, String)>,
|
||||
/// Built-in entries the user deleted.
|
||||
@@ -152,6 +155,7 @@ impl Default for AutoCorrectPrefs {
|
||||
border_lines: true,
|
||||
tab_indent: false,
|
||||
heading_styles: false,
|
||||
double_space_period: false,
|
||||
entries: Vec::new(),
|
||||
removed: Vec::new(),
|
||||
exceptions: Vec::new(),
|
||||
@@ -344,6 +348,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
("borderLines", &mut ac.border_lines),
|
||||
("tabIndent", &mut ac.tab_indent),
|
||||
("headingStyles", &mut ac.heading_styles),
|
||||
("doubleSpacePeriod", &mut ac.double_space_period),
|
||||
("autoAddFirstLetter", &mut ac.auto_add_first_letter),
|
||||
("autoAddInitialCaps", &mut ac.auto_add_initial_caps),
|
||||
] {
|
||||
@@ -420,6 +425,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"borderLines": ac.border_lines,
|
||||
"tabIndent": ac.tab_indent,
|
||||
"headingStyles": ac.heading_styles,
|
||||
"doubleSpacePeriod": ac.double_space_period,
|
||||
"autoAddFirstLetter": ac.auto_add_first_letter,
|
||||
"autoAddInitialCaps": ac.auto_add_initial_caps,
|
||||
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
|
||||
@@ -462,7 +468,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
|
||||
.params(
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "doubleSpacePeriod"?: bool (Add a period with double-space; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
|
||||
|
||||
@@ -892,6 +892,72 @@ fn find_replace_wildcards() {
|
||||
assert_eq!(run(&mut s, "edit.find", json!({"text": "eg", "matchSuffix": false, "ignorePunct": true}))["count"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_expands_groups() {
|
||||
// #4177: with Regular expressions on, Replace with expands capture groups.
|
||||
let rep = |text: &str, q: &str, with: &str| rep_mode(text, q, with, json!({"regex": true}));
|
||||
let names = "Smith, John and Doe, Jane";
|
||||
// The issue's cases: `\n` and `$n` swap words; `${name}` names a group.
|
||||
assert_eq!(rep(names, r"(\w+), (\w+)", r"\2 \1"), "John Smith and Jane Doe");
|
||||
assert_eq!(rep(names, r"(\w+), (\w+)", "$2 $1"), "John Smith and Jane Doe");
|
||||
assert_eq!(rep("2024-05", r"(?<y>\d{4})-", "${y}/"), "2024/05");
|
||||
// Braced numbers, `$0` / `${0}` (whole match), `$$` (a dollar sign).
|
||||
assert_eq!(rep("ab", "(a)(b)", "${2}${1}"), "ba");
|
||||
assert_eq!(rep("ab", "a", "[$0|${0}]"), "[a|a]b");
|
||||
assert_eq!(rep("5", r"(\d)", "$$$1"), "$5");
|
||||
// `$name` takes the longest run of letters, digits and `_` (the regex crate's rule).
|
||||
assert_eq!(rep("ab", "(?<x>a)", "$x_"), "b");
|
||||
assert_eq!(rep("ab", "(?<x>a)", "${x}_"), "a_b");
|
||||
assert_eq!(rep("ab", "(a)", "$1a"), "b");
|
||||
// Missing, unknown and out-of-range groups expand to nothing.
|
||||
assert_eq!(rep("ab", "(a)", "<$2>"), "<>b");
|
||||
assert_eq!(rep("ab", "(a)", r"<\9>"), "<>b");
|
||||
assert_eq!(rep("ab", "(a)", "<${nope}>"), "<>b");
|
||||
assert_eq!(rep("ab", "(a)", "<$99999999999999999999999>"), "<>b");
|
||||
assert_eq!(rep("ab", "(a)|(z)", "<$2>"), "<>b");
|
||||
// A `$` that starts no group, and an unclosed brace, stay as written.
|
||||
assert_eq!(rep("ab", "a", "$ ${x"), "$ ${xb");
|
||||
// `\t` a tab, `\\` a backslash; other escapes stay as written.
|
||||
assert_eq!(rep("ab", "a", r"\t\\\q"), "\t\\\\qb");
|
||||
// Word's `^` codes still work.
|
||||
assert_eq!(rep("ab", "a", "^&^t^&"), "a\tab");
|
||||
// `\$` and `\^` keep the character as typed (CodeRabbit on #4177).
|
||||
assert_eq!(rep("ab", "(a)", r"\$1\^p"), "$1^pb");
|
||||
// `special: false` turns off the `^` codes only; groups still expand (CodeRabbit on #4177).
|
||||
assert_eq!(rep_mode("ab", "(a)", "$1^t", json!({"regex": true, "special": false})), "a^tb");
|
||||
// `\n` is a paragraph mark: it splits the paragraph, as `^p` does, in one undo step.
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "one, two"}));
|
||||
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": r"(\w+), (\w+)", "with": r"$1\n$2", "regex": true}))["replaced"], 1);
|
||||
assert_eq!(text(&s), "one\ntwo");
|
||||
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "one, two");
|
||||
run(&mut s, "document.setText", json!({"text": "a-b"}));
|
||||
run(&mut s, "edit.replaceAll", json!({"text": "-", "with": "^p", "regex": true}));
|
||||
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2);
|
||||
// Plain and wildcard Replace are unchanged: `$1` is literal there.
|
||||
assert_eq!(rep_mode("ab", "a", "$1", json!({})), "$1b");
|
||||
assert_eq!(rep_mode("ab", "(a)", "$1\\1", json!({"wildcards": true})), "$1ab");
|
||||
// Hostile Replace text never panics.
|
||||
run(&mut s, "document.setText", json!({"text": "ab ab"}));
|
||||
for with in ["$", "${", "${}", "${0", "$\u{e9}", "\\", "${\u{1F600}}", "$18446744073709551616", "${-1}", "$$$", "\\0\\n\\", "^"] {
|
||||
let _ = s.run("edit.replaceAll", &json!({"text": "(a)(?<n>b)?", "with": with, "regex": true}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The body text after Replace All of `q` with `with` in `text`, matching case, with `extra` options.
|
||||
fn rep_mode(text: &str, q: &str, with: &str, extra: serde_json::Value) -> String {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": text}));
|
||||
let mut v = json!({"text": q, "with": with, "matchCase": true});
|
||||
if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
run(&mut s, "edit.replaceAll", v);
|
||||
self::text(&s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_find_patterns_are_bounded() {
|
||||
let mut s = s();
|
||||
@@ -10608,4 +10674,126 @@ fn text_insert_marks_automatic_rewrites_as_transformed() {
|
||||
run(&mut session, "text.insert", json!({"text": " "}));
|
||||
assert!(session.ime_edit_outcome().transformed, "dash AutoFormat: {:?}", session.ime_edit_outcome());
|
||||
assert_eq!(text(&session), "A\u{2014}b ");
|
||||
|
||||
let mut session = s();
|
||||
run(&mut session, "tools.autocorrect", json!({"doubleSpacePeriod": true}));
|
||||
for c in "Hello ".chars() {
|
||||
run(&mut session, "text.insert", json!({"text": c.to_string()}));
|
||||
}
|
||||
let outcome = session.ime_edit_outcome();
|
||||
assert!(outcome.transformed && outcome.content_changed && outcome.state_changed, "double-space period: {outcome:?}");
|
||||
assert_eq!(text(&session), "Hello. ");
|
||||
run(&mut session, "edit.undo", json!({}));
|
||||
assert_eq!(text(&session), "Hello ", "undo restores the spaces in one step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_set_keeps_imported_styles_latent_styles_and_defaults() {
|
||||
use wordcraft_doc::props::{Rgb, TextColor};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
for name in ["basic", "lines", "shaded", "casual", "centered", "minimalist", "title"] {
|
||||
let mut s = s();
|
||||
let mut body = s.doc.styles.get("Normal").unwrap().clone();
|
||||
body.id = "BodyText".into();
|
||||
body.name = "Body Text".into();
|
||||
body.builtin = true;
|
||||
body.based_on = Some("Normal".into());
|
||||
body.chr.size = Some(20.0);
|
||||
body.chr.color = Some(TextColor::Rgb(Rgb(0xFF, 0, 0)));
|
||||
let grid = Style { id: "GridTable4".into(), name: "Grid Table 4".into(), kind: StyleKind::Table, builtin: true, ..Default::default() };
|
||||
s.doc.styles.styles.push(body.clone());
|
||||
s.doc.styles.styles.push(grid.clone());
|
||||
s.doc.styles.latent_styles = Some("<w:latentStyles/>".into());
|
||||
s.doc.styles.default_chr.lang = Some("de-DE".into());
|
||||
s.doc.styles.default_chr.size = Some(11.0);
|
||||
run(&mut s, "design.styleSet", json!({ "name": name }));
|
||||
assert_eq!(s.doc.styles.get("BodyText"), Some(&body), "{name}: BodyText");
|
||||
assert_eq!(s.doc.styles.get("GridTable4"), Some(&grid), "{name}: GridTable4");
|
||||
assert_eq!(s.doc.styles.latent_styles.as_deref(), Some("<w:latentStyles/>"), "{name}");
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE"), "{name}");
|
||||
assert_eq!(s.doc.styles.default_chr.size, Some(11.0), "{name}");
|
||||
assert_eq!(s.doc.styles.styles.iter().filter(|x| x.id == "Heading1").count(), 1, "{name}");
|
||||
let saved = crate::io::save_bytes("x.docx", &s.doc).unwrap();
|
||||
let back = crate::io::open_bytes("x.docx", &saved).unwrap();
|
||||
assert!(back.styles.get("BodyText").is_some(), "{name}: BodyText after round trip");
|
||||
}
|
||||
let mut s = s();
|
||||
run(&mut s, "design.styleSet", json!({ "name": "title" }));
|
||||
assert_eq!(s.doc.styles.get("Title").unwrap().chr.size, Some(36.0));
|
||||
}
|
||||
|
||||
fn style_size(s: &Session, id: &str) -> f32 {
|
||||
s.doc.styles.resolve_char(Some(id), &Default::default()).size
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_font_sizes_adds_points_to_every_style() {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let ids: Vec<String> = s.doc.styles.styles.iter().map(|x| x.id.clone()).collect();
|
||||
let before: Vec<f32> = ids.iter().map(|i| style_size(&s, i)).collect();
|
||||
let base = s.doc.styles.resolve_char(None, &Default::default()).size;
|
||||
let runs = serde_json::to_string(&s.doc.body).unwrap();
|
||||
run(&mut s, "design.scaleFontSizes", json!({"delta": 2}));
|
||||
// Every style (explicit or inherited) grows by exactly 2, once.
|
||||
for (i, b) in ids.iter().zip(&before) {
|
||||
assert_eq!(style_size(&s, i), b + 2.0, "{i}");
|
||||
}
|
||||
assert_eq!(s.doc.styles.resolve_char(None, &Default::default()).size, base + 2.0);
|
||||
assert_eq!(serde_json::to_string(&s.doc.body).unwrap(), runs, "runs untouched");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
for (i, b) in ids.iter().zip(&before) {
|
||||
assert_eq!(style_size(&s, i), *b, "undo {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_font_sizes_percent_and_clamp_and_errors() {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let n = style_size(&s, "Normal");
|
||||
run(&mut s, "design.scaleFontSizes", json!({"percent": 10}));
|
||||
assert_eq!(style_size(&s, "Normal"), (n * 1.1 * 2.0).round() / 2.0);
|
||||
run(&mut s, "design.scaleFontSizes", json!({"delta": -500}));
|
||||
assert_eq!(style_size(&s, "Normal"), 1.0);
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"delta": 1e9}),
|
||||
json!({"percent": -100}),
|
||||
json!({"percent": 1e9}),
|
||||
json!({"delta": "x"}),
|
||||
json!({"delta": 0}),
|
||||
json!({"percent": 0}),
|
||||
json!({"delta": 1, "percent": 10}),
|
||||
json!({"delta": 2, "percent": "x"}),
|
||||
json!({"percent": 10, "delta": null}),
|
||||
] {
|
||||
assert!(s.run("design.scaleFontSizes", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Style, StyleKind, styles::TablePart};
|
||||
let mut s = s();
|
||||
s.doc.styles.default_chr.size = None;
|
||||
s.doc.styles.default_chr.size_cs = Some(10.0);
|
||||
let mut ch = Style { id: "Big".into(), name: "Big".into(), kind: StyleKind::Character, ..Default::default() };
|
||||
ch.chr.size = Some(20.0);
|
||||
ch.chr.size_cs = Some(22.0);
|
||||
let mut tb = Style { id: "Grid".into(), name: "Grid".into(), kind: StyleKind::Table, ..Default::default() };
|
||||
let mut parts = wordcraft_doc::styles::TableStyleParts::default();
|
||||
parts.header_chr.size = Some(14.0);
|
||||
parts.regions.entry(TablePart::Band2Horz).or_default().chr.size = Some(8.0);
|
||||
tb.table = Some(parts);
|
||||
s.doc.styles.styles.extend([ch, tb]);
|
||||
run(&mut s, "design.scaleFontSizes", json!({"delta": 2}));
|
||||
let st = &s.doc.styles;
|
||||
assert_eq!(st.default_chr.size, Some(13.0), "unset default is 11 pt, made explicit");
|
||||
assert_eq!(st.default_chr.size_cs, Some(12.0));
|
||||
let big = CharProps { style: Some("Big".into()), ..Default::default() };
|
||||
assert_eq!(st.resolve_char(None, &big).size, 22.0);
|
||||
assert_eq!(st.resolve_char(None, &big).size_cs, 24.0);
|
||||
let grid = st.styles.iter().find(|x| x.id == "Grid").and_then(|x| x.table.as_ref()).unwrap();
|
||||
assert_eq!(grid.header_chr.size, Some(16.0));
|
||||
assert_eq!(grid.regions.get(&TablePart::Band2Horz).unwrap().chr.size, Some(10.0));
|
||||
}
|
||||
|
||||
@@ -3341,3 +3341,48 @@ fn tab_with_a_whole_paragraph_selected_undoes_indent_and_selection_together() {
|
||||
assert_eq!(s.sel, selected, "and restores the selection the indent was applied to");
|
||||
assert_eq!(paras(&s), vec![para("Hello world", None)]);
|
||||
}
|
||||
|
||||
/// #2848: "Add a period with double-space" (an AutoFormat As You Type switch, off by default):
|
||||
/// a second Space after a word, a number or a closing bracket or quote turns the first into a
|
||||
/// period. Undo puts the two spaces back; other punctuation, a lone space and a run of three
|
||||
/// spaces are left as typed.
|
||||
#[test]
|
||||
fn double_space_adds_a_period_when_asked() {
|
||||
let with_option = |keys: &str| {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", json!({"doubleSpacePeriod": true}));
|
||||
typ(&mut s, keys);
|
||||
s
|
||||
};
|
||||
let text = |s: &Session| s.doc.plain_text(StoryRef::Body);
|
||||
// Off unless asked for.
|
||||
assert_eq!(text(&typed("Hello world")), "Hello world");
|
||||
assert!(!s().prefs.autocorrect.double_space_period);
|
||||
// On: the second space makes the period, and the next sentence starts with a capital.
|
||||
assert_eq!(text(&with_option("Hello world")), "Hello. world");
|
||||
assert_eq!(text(&with_option("Hello world ")), "Hello. World ");
|
||||
assert_eq!(text(&with_option("See page 5 Then")), "See page 5. Then");
|
||||
assert_eq!(text(&with_option("(it is done) Next")), "(it is done). Next");
|
||||
assert_eq!(text(&with_option("He said \"stop\" Next")), "He said “stop”. Next");
|
||||
// Undo brings the two spaces back, in one step.
|
||||
let mut t = with_option("Hello ");
|
||||
assert_eq!(text(&t), "Hello. ");
|
||||
run(&mut t, "edit.undo", json!({}));
|
||||
assert_eq!(text(&t), "Hello ");
|
||||
// Left alone: other punctuation before the spaces, a lone space, spaces at the start of a
|
||||
// paragraph or after a tab, and a third space.
|
||||
for keys in ["Hello. x", "Hello, x", "Hello? x", "Hello; x", "Hello: x", "Hello x", " x", "\n x", "a\t x"] {
|
||||
let want = typed(keys);
|
||||
assert_eq!(text(&with_option(keys)), text(&want), "{keys:?}");
|
||||
}
|
||||
assert_eq!(text(&with_option("Hello x")), "Hello. x");
|
||||
// A selection typed over, and the master switch.
|
||||
let mut t = with_option("Hello world");
|
||||
run(&mut t, "select.all", json!({}));
|
||||
run(&mut t, "text.insert", json!({"text": " "}));
|
||||
assert_eq!(text(&t), " ");
|
||||
let mut t = s();
|
||||
run(&mut t, "tools.autocorrect", json!({"doubleSpacePeriod": true, "enabled": false}));
|
||||
typ(&mut t, "Hello world");
|
||||
assert_eq!(text(&t), "Hello world");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user