Change Case follows each run's language for Turkic casing (#3479) (#3480)

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
This commit is contained in:
SlavenDj
2026-10-23 17:14:34 -04:00
committed by GitHub
co-authored by aiongg
parent 7086f8a425
commit 3ed7fe977c
4 changed files with 75 additions and 14 deletions
+29 -9
View File
@@ -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<bool> = 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<String> {
/// 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<String> {
let chars: Vec<char> = 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<String> {
// Sentence case.
let n = if up && c.is_alphabetic() {
up = false;
c.to_uppercase().collect()
upper(c)
} else {
lower(i, c)
};
+3 -3
View File
@@ -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(),
+40
View File
@@ -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.
+3 -2
View File
@@ -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