diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 1d0ee5a7d..0cf413dd9 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -7,6 +7,7 @@ use wordcraft_doc::resolve::ResolvedChar; use wordcraft_doc::{InlineObject, PartKind, Pos, RevisionKind, StoryRef}; use super::sel_result; +use super::tools::{is_turkic, lower_char, upper_char}; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; /// Word's font size list. @@ -453,7 +454,16 @@ fn change_case(s: &mut Session, v: &Value) -> CmdResult { let to = if path == b.path { b.off } else { p.len() }; let chars = live_chars(p, from, to); let src: String = chars.iter().map(|(_, c)| c).collect(); - case_groups(p, &chars, &convert_chars(&src, &mode, from == 0)) + // Turkish, Azerbaijani and Crimean Tatar text follows the same case mapping as + // AutoCorrect (#1923); each run's own language decides (#3479). + let turkic: Vec = chars + .iter() + .map(|(off, _)| { + let lang = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(*off)).lang; + is_turkic(lang.as_deref().or(s.editing_lang.as_deref())) + }) + .collect(); + case_groups(p, &chars, &convert_chars(&src, &mode, from == 0, &turkic)) }; if groups.is_empty() { continue; @@ -587,37 +597,47 @@ fn is_title(t: &str) -> bool { #[cfg(test)] fn convert(src: &str, mode: &str, para_start: bool) -> String { - convert_chars(src, mode, para_start).concat() + let turkic = vec![false; src.chars().count()]; + convert_chars(src, mode, para_start, &turkic).concat() } /// The case-converted form of each character of `src`, in order (one entry per char, so a -/// change of length such as ß → SS or İ → i̇ can be applied run by run). -fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec { +/// change of length such as ß → SS or İ → i̇ can be applied run by run). `turkic` is one flag +/// per char: true when that character's run is Turkish, Azerbaijani or Crimean Tatar. +fn convert_chars(src: &str, mode: &str, para_start: bool, turkic: &[bool]) -> Vec { let chars: Vec = src.chars().collect(); // Greek capital sigma lowers to the final form at the end of a word (as `str::to_lowercase`). let lower = |i: usize, c: char| -> String { let prev = i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_alphabetic()); let next = chars.get(i + 1).is_some_and(|n| n.is_alphabetic()); - if c == 'Σ' && prev && !next { "ς".to_string() } else { c.to_lowercase().collect() } + if c == 'Σ' && prev && !next { + "ς".to_string() + } else if turkic.get(i).copied().unwrap_or(false) { + lower_char(c, true) + } else { + c.to_lowercase().collect() + } }; // Title case capitalises the first word of any selection; sentence case only at a paragraph start. let mut up = mode == "title" || para_start; let mut out = Vec::with_capacity(chars.len()); for (i, &c) in chars.iter().enumerate() { + let turkic = turkic.get(i).copied().unwrap_or(false); + let upper = |c: char| upper_char(c, turkic); let new = match mode { - "upper" => c.to_uppercase().collect(), + "upper" => upper(c), "lower" => lower(i, c), "toggle" => { if c.is_uppercase() { lower(i, c) } else { - c.to_uppercase().collect() + upper(c) } } "title" => { let n = if up && c.is_alphabetic() { up = false; - c.to_uppercase().collect() + upper(c) } else { lower(i, c) }; @@ -630,7 +650,7 @@ fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec { // Sentence case. let n = if up && c.is_alphabetic() { up = false; - c.to_uppercase().collect() + upper(c) } else { lower(i, c) }; diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index dde4631ad..3403cae1f 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -1028,7 +1028,7 @@ fn prev_token(before: &str) -> String { } /// Turkish, Azerbaijani (and Crimean Tatar) map `i`/`İ` and `ı`/`I` as pairs (#1923). -fn is_turkic(lang: Option<&str>) -> bool { +pub(crate) fn is_turkic(lang: Option<&str>) -> bool { if wordcraft_proof::ProofLang::from_tag(lang) == wordcraft_proof::ProofLang::Tr { return true; // tr, tur, any region, same normalisation as proofing } @@ -1038,7 +1038,7 @@ fn is_turkic(lang: Option<&str>) -> bool { } /// A letter in capitals: `i` → `İ` in Turkic languages, otherwise the Unicode default. -fn upper_char(c: char, turkic: bool) -> String { +pub(crate) fn upper_char(c: char, turkic: bool) -> String { if turkic && c == 'i' { return "\u{130}".to_string(); } @@ -1047,7 +1047,7 @@ fn upper_char(c: char, turkic: bool) -> String { /// A letter in lowercase: `I` → `ı` in Turkic languages. `İ` is always the single letter `i`, /// never `i` plus a combining dot. -fn lower_char(c: char, turkic: bool) -> String { +pub(crate) fn lower_char(c: char, turkic: bool) -> String { match c { 'I' if turkic => "\u{131}".to_string(), '\u{130}' => "i".to_string(), diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 0f44e8a14..54715e015 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -2075,6 +2075,46 @@ fn autocorrect_capitalisation_follows_turkic_case_mapping() { assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "no combining dot in any language"); } +/// #3479: Format > Change Case follows the same Turkic case mapping as AutoCorrect, using +/// each run's own language; other languages keep the Unicode default. +#[test] +fn change_case_follows_turkic_case_mapping() { + for tag in ["tr-TR", "az-AZ"] { + for (mode, input, want) in [ + ("upper", "istanbul", "İSTANBUL"), + ("lower", "IŞIK", "ışık"), + ("title", "istanbul güzel", "İstanbul Güzel"), + ("sentence", "istanbul. izmir", "İstanbul. İzmir"), + ("toggle", "iI", "İı"), + ] { + let mut s = s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + run(&mut s, "text.insert", json!({"text": input, "raw": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": mode})); + assert_eq!(s.doc.plain_text(StoryRef::Body), want, "{tag} {mode}"); + } + } + // Other languages keep the Unicode default. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "istanbul", "raw": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "title"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul", "en-US"); + // Mixed runs: each run's own language decides. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "IŞIK IŞIK", "raw": true})); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)})); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5), "extend": true})); + run(&mut s, "review.language", json!({"lang": "tr-TR"})); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 6)})); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 11), "extend": true})); + run(&mut s, "review.language", json!({"lang": "en-US"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "lower"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "ışık işik", "mixed runs"); +} + // Spelling as you type (#1552): the word being typed gets no red squiggle until the caret leaves // it; F7 and the Spelling pane still report it. See docs/typing-parity.md. Paragraphs start with // a correct word so AutoCorrect's sentence capital doesn't change the misspelled one. diff --git a/docs/typing-parity.md b/docs/typing-parity.md index df0fb9a22..5f8d61472 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -187,8 +187,9 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin `İngilizce`, `ılık` → `Ilık`; observed in Word for Mac with Turkish proofing). Lowercasing `İ` gives the single letter `i` in every language, never `i` + combining dot. Other languages keep the Unicode default (`istanbul` → `Istanbul` in English) - (`autocorrect_capitalisation_follows_turkic_case_mapping`). Format > Change Case is not yet - Turkish-aware. + (`autocorrect_capitalisation_follows_turkic_case_mapping`); Format > Change Case follows the + same Turkic mapping from each run's language, while other languages keep the Unicode default + (`change_case_follows_turkic_case_mapping`, #3479). - Names of days are capitalised (`monday` → `Monday`). AutoCorrect Options (#412, `tools.autocorrect`, saved per user) switch each of these off: replacements, sentence and table-cell capitals, days, smart quotes, fractions, ordinals, dashes, links, bulleted and