Browse / github.com/storytold/wordcraft / patch
#4317: Vietnamese sorting follows the Vietnamese alphabet and tone order
#4317: Vietnamese sorting follows the Vietnamese alphabet and tone order #4317 was filed against GridCraft, but WordCraft has the same gap: `crates/doc/src/collate.rs` had no Vietnamese tailoring and no collation entry for `ơ ư` or any toned vowel with two marks (`ấ ặ ữ`…). So Table › Sort, Home › Sort, the index (and its letter headings), the bibliography and the Table of Authorities put `Ăn`, `Đang` and `Ẩn` after `z`. Opened in this repo, the issue sounded like it was about WordCraft, so this PR fixes WordCraft's collation. ## What changes - **`vi` tailoring (CLDR `vi`)**: `ă â` after `a`, `đ` after `d`, `ê` after `e`, `ô ơ` after `o`, `ư` after `u`. - **Tones
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- MeHail-02 (imported from pull request #4483, open)
- Base
6a2b520fa49aa0d0595cea36589aaf16ca371cd3- Size
- +177 −11 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
7ce3a0451bb0f9fcad2a9a54
How it went
No other operator has reported using it yet.
Diff
raw · crates/doc/src/collate.rs docs/localization-parity.md
@@ -5,7 +5,8 @@ //! with `e` (`resume` < `résumé` < `rex`) unless the language makes it a letter of its own: //! Polish puts `ą` right after `a` and `ż` after `ź` after `z`, Swedish puts `å ä ö` after `z`, //! Czech puts `ch` after `h`, Spanish `ñ` after `n`, Hungarian `cs`, `gy`, `sz`… after their-//! first letter. The per-language tables follow the published alphabets of those languages.+//! first letter, Vietnamese `ă â` after `a`, `đ` after `d`, `ô ơ` after `o`, with the tones as+//! accents. The per-language tables follow the published alphabets of those languages. //! Text without diacritics sorts exactly as a case-insensitive code-point compare. use std::cmp::Ordering;@@ -171,6 +172,40 @@ const IS: &[Tailor] = &[ t("æ", 'z', 2), t("ö", 'z', 3), ];+const VI: &[Tailor] = &[t("đ", 'd', 1)];++/// Vietnamese vowels (CLDR `vi`): the Latin letter each one sorts after, its rank there, and the+/// letter followed by its toned forms in dictionary order of the tones: grave (huyền), hook above+/// (hỏi), tilde (ngã), acute (sắc), dot below (nặng). The tone is an accent: `ma` < `mà` < `mả`+/// < `mã` < `má` < `mạ`, and all of them sort before `mai`.+const VI_VOWELS: &[(char, u16, &str)] = &[+ ('a', 0, "aàảãáạ"),+ ('a', 1, "ăằẳẵắặ"),+ ('a', 2, "âầẩẫấậ"),+ ('e', 0, "eèẻẽéẹ"),+ ('e', 1, "êềểễếệ"),+ ('i', 0, "iìỉĩíị"),+ ('o', 0, "oòỏõóọ"),+ ('o', 1, "ôồổỗốộ"),+ ('o', 2, "ơờởỡớợ"),+ ('u', 0, "uùủũúụ"),+ ('u', 1, "ưừửữứự"),+ ('y', 0, "yỳỷỹýỵ"),+];++/// Where languages other than Vietnamese put a toned or modified Vietnamese vowel among the accents+/// of its Latin letter: after the [`DECOMP`] marks and the combining marks.+const VI_FOLD: u16 = 160;++/// A lowercase Vietnamese vowel: `(Latin letter, rank after it, the letter without its tone, tone)`+/// with tone 0 for none and 1–5 in the order of [`VI_VOWELS`].+fn vi_vowel(c: char) -> Option<(char, u16, &'static str, u16)> {+ VI_VOWELS.iter().find_map(|(after, rank, forms)| {+ let tone = forms.chars().position(|x| x == c)?;+ let letter = forms.get(..forms.chars().next()?.len_utf8())?;+ Some((*after, *rank, letter, u16::try_from(tone).ok()?))+ })+} /// The primary language subtag of a BCP 47 tag (`pl-PL` → `pl`), lowercased. pub fn language(lang: Option<&str>) -> String {@@ -195,6 +230,7 @@ fn tailoring(lang: Option<&str>) -> &'static [Tailor] { "lv" => LV, "is" => IS, "sl" => LT,+ "vi" => VI, _ => &[], } }@@ -332,6 +368,57 @@ const COMPOSE: &[(char, char, char)] = &[ ('z', '\u{302}', 'ẑ'), ('z', '\u{307}', 'ż'), ('z', '\u{30c}', 'ž'),+ // Vietnamese: the hook above, dot below and horn, then the tones of `ă â ê ô ơ ư`. Decomposed+ // text puts a dot below before a circumflex or breve (`ậ` is `a` U+0323 U+0302), typed text+ // often after it.+ ('a', '\u{309}', 'ả'),+ ('a', '\u{323}', 'ạ'),+ ('e', '\u{309}', 'ẻ'),+ ('e', '\u{323}', 'ẹ'),+ ('i', '\u{309}', 'ỉ'),+ ('i', '\u{323}', 'ị'),+ ('o', '\u{309}', 'ỏ'),+ ('o', '\u{31b}', 'ơ'),+ ('o', '\u{323}', 'ọ'),+ ('u', '\u{309}', 'ủ'),+ ('u', '\u{31b}', 'ư'),+ ('u', '\u{323}', 'ụ'),+ ('y', '\u{309}', 'ỷ'),+ ('y', '\u{323}', 'ỵ'),+ ('ạ', '\u{302}', 'ậ'),+ ('ạ', '\u{306}', 'ặ'),+ ('ẹ', '\u{302}', 'ệ'),+ ('ọ', '\u{302}', 'ộ'),+ ('ă', '\u{300}', 'ằ'),+ ('ă', '\u{309}', 'ẳ'),+ ('ă', '\u{303}', 'ẵ'),+ ('ă', '\u{301}', 'ắ'),+ ('ă', '\u{323}', 'ặ'),+ ('â', '\u{300}', 'ầ'),+ ('â', '\u{309}', 'ẩ'),+ ('â', '\u{303}', 'ẫ'),+ ('â', '\u{301}', 'ấ'),+ ('â', '\u{323}', 'ậ'),+ ('ê', '\u{300}', 'ề'),+ ('ê', '\u{309}', 'ể'),+ ('ê', '\u{303}', 'ễ'),+ ('ê', '\u{301}', 'ế'),+ ('ê', '\u{323}', 'ệ'),+ ('ô', '\u{300}', 'ồ'),+ ('ô', '\u{309}', 'ổ'),+ ('ô', '\u{303}', 'ỗ'),+ ('ô', '\u{301}', 'ố'),+ ('ô', '\u{323}', 'ộ'),+ ('ơ', '\u{300}', 'ờ'),+ ('ơ', '\u{309}', 'ở'),+ ('ơ', '\u{303}', 'ỡ'),+ ('ơ', '\u{301}', 'ớ'),+ ('ơ', '\u{323}', 'ợ'),+ ('ư', '\u{300}', 'ừ'),+ ('ư', '\u{309}', 'ử'),+ ('ư', '\u{303}', 'ữ'),+ ('ư', '\u{301}', 'ứ'),+ ('ư', '\u{323}', 'ự'), ('е', '\u{308}', 'ё'), ('и', '\u{306}', 'й'), ];@@ -365,8 +452,9 @@ struct Elem<'a> { own: bool, } -/// The next collation element of `rest` (lowercase), and its length in bytes.-fn next_elem<'a>(rest: &'a str, rules: &[Tailor]) -> Option<(Elem<'a>, usize)> {+/// The next collation element of `rest` (lowercase), and its length in bytes. `vi` sorts+/// Vietnamese vowels as Vietnamese does.+fn next_elem<'a>(rest: &'a str, rules: &[Tailor], vi: bool) -> Option<(Elem<'a>, usize)> { if let Some(r) = rules.iter().filter(|r| rest.starts_with(r.seq)).max_by_key(|r| r.seq.len()) { let text = rest.get(..r.seq.len())?; return Some((Elem { primary: vec![weight(r.after, r.rank)], sec: r.sec, text, own: true }, r.seq.len()));@@ -374,6 +462,13 @@ fn next_elem<'a>(rest: &'a str, rules: &[Tailor]) -> Option<(Elem<'a>, usize)> { let c = rest.chars().next()?; let n = c.len_utf8(); let text = rest.get(..n)?;+ let vowel = vi_vowel(c);+ if vi {+ // The text is the letter without its tone: `ấm` files under `Â` in an index.+ if let Some((after, rank, letter, tone)) = vowel {+ return Some((Elem { primary: vec![weight(after, u32::from(rank))], sec: tone, text: letter, own: true }, n));+ }+ } if let Some((_, base, mark)) = DECOMP.iter().find(|(x, _, _)| *x == c) { return Some((Elem { primary: base.chars().map(|b| weight(b, 0)).collect(), sec: *mark, text, own: false }, n)); }@@ -381,6 +476,10 @@ fn next_elem<'a>(rest: &'a str, rules: &[Tailor]) -> Option<(Elem<'a>, usize)> { if ('\u{300}'..='\u{36f}').contains(&c) { return Some((Elem { primary: Vec::new(), sec: 32 + (c as u32 - 0x300) as u16, text, own: false }, n)); }+ // Other languages sort a Vietnamese vowel with its Latin letter, as an accented form of it.+ if let Some((after, rank, _, tone)) = vowel.filter(|(_, rank, _, tone)| *rank > 0 || *tone > 0) {+ return Some((Elem { primary: vec![weight(after, 0)], sec: VI_FOLD + rank * 8 + tone, text, own: false }, n));+ } Some((Elem { primary: vec![weight(c, 0)], sec: 0, text, own: false }, n)) } @@ -398,10 +497,11 @@ pub struct SortKey { pub fn sort_key(s: &str, lang: Option<&str>) -> SortKey { let text = lower_composed(s); let rules = tailoring(lang);+ let vi = language(lang) == "vi"; let mut key = SortKey::default(); let mut i = 0; while let Some(rest) = text.get(i..).filter(|r| !r.is_empty()) {- let Some((e, n)) = next_elem(rest, rules) else { break };+ let Some((e, n)) = next_elem(rest, rules, vi) else { break }; i += n.max(1); if e.primary.is_empty() { if let Some(last) = key.secondary.last_mut() {@@ -424,14 +524,20 @@ pub fn collate(a: &str, b: &str, lang: Option<&str>) -> Ordering { } /// The index heading letter `s` files under in language `lang`: `Ł` for `Łódź` in Polish, `L`-/// in English; `Ch` for `chata` in Czech; `A` for `Äpfel` in German, `Ä` in Swedish.+/// in English; `Ch` for `chata` in Czech; `A` for `Äpfel` in German, `Ä` in Swedish; `Â` for+/// `ấm` in Vietnamese, `A` in English. pub fn heading_letter(s: &str, lang: Option<&str>) -> Option<String> { let low = lower_composed(s.trim_start());- let (e, _) = next_elem(&low, tailoring(lang))?;+ let (e, _) = next_elem(&low, tailoring(lang), language(lang) == "vi")?; let shown = if e.own || e.sec == 0 { e.text.to_string() } else {- DECOMP.iter().find(|(x, _, _)| e.text.starts_with(*x)).map(|(_, b, _)| b.chars().take(1).collect()).unwrap_or_else(|| e.text.to_string())+ DECOMP+ .iter()+ .find(|(x, _, _)| e.text.starts_with(*x))+ .map(|(_, b, _)| b.chars().take(1).collect())+ .or_else(|| e.text.chars().next().and_then(vi_vowel).map(|(after, ..)| after.to_string()))+ .unwrap_or_else(|| e.text.to_string()) }; let mut cs = shown.chars(); let first = cs.next()?;@@ -522,8 +628,56 @@ mod tests { assert_eq!(collate("ABC", "abc", None), Ordering::Equal); } + #[test]+ fn vietnamese_alphabet_and_tones() {+ // #4317: Vietnamese letters sorted after `z`.+ assert_eq!(sorted(&["An", "Dung", "Khoa", "Kim", "Ăn", "Đang", "Ẩn"], "vi-VN"), ["An", "Ăn", "Ẩn", "Dung", "Đang", "Khoa", "Kim"]);+ let alphabet = [+ "a", "ă", "â", "b", "c", "d", "đ", "e", "ê", "g", "h", "i", "k", "l", "m", "n", "o", "ô", "ơ", "p", "q", "r", "s", "t", "u", "ư", "v",+ "x", "y",+ ];+ let mut shuffled = alphabet;+ shuffled.reverse();+ assert_eq!(sorted(&shuffled, "vi"), alphabet);+ // Tones are accents, in dictionary order: none, grave, hook, tilde, acute, dot below.+ assert_eq!(sorted(&["mạ", "má", "mã", "mả", "mà", "ma"], "vi"), ["ma", "mà", "mả", "mã", "má", "mạ"]);+ assert_eq!(sorted(&["mát", "màn", "mạ"], "vi"), ["mạ", "màn", "mát"]);+ assert_eq!(sorted(&["ba", "ấm", "âm", "ăn", "an"], "vi"), ["an", "ăn", "âm", "ấm", "ba"]);+ assert_eq!(sorted(&["ước", "ung", "ở", "ôm", "ổn", "ong"], "vi"), ["ong", "ôm", "ổn", "ở", "ung", "ước"]);+ assert_eq!(collate("ĐẶNG", "đặng", Some("vi")), Ordering::Equal);+ }++ #[test]+ fn vietnamese_decomposed_letters_match_their_composed_forms() {+ // A toneless letter + combining tone (Windows "Unicode tổ hợp" input), and full NFD, where+ // a dot below comes before the circumflex and after the horn.+ for (composed, decomposed) in [+ ("ấm", "â\u{301}m"),+ ("ấm", "a\u{302}\u{301}m"),+ ("Ậu", "A\u{323}\u{302}u"),+ ("ợ", "o\u{31b}\u{323}"),+ ("ửa", "u\u{31b}\u{309}a"),+ ("mả", "ma\u{309}"),+ ] {+ assert_eq!(collate(composed, decomposed, Some("vi-VN")), Ordering::Equal, "{composed}");+ assert_eq!(heading_letter(composed, Some("vi")), heading_letter(decomposed, Some("vi")), "{composed}");+ }+ }++ #[test]+ fn other_languages_sort_vietnamese_vowels_with_their_latin_letter() {+ assert_eq!(sorted(&["zebra", "Ẩn", "bánh", "Ước", "vợ"], "en-US"), ["Ẩn", "bánh", "Ước", "vợ", "zebra"]);+ assert_eq!(sorted(&["mo", "mợ", "mỏ", "mơ"], "en"), ["mo", "mỏ", "mơ", "mợ"]);+ assert_eq!(heading_letter("Ẩn", Some("en")).as_deref(), Some("A"));+ assert_eq!(heading_letter("ước", None).as_deref(), Some("U"));+ }+ #[test] fn heading_letters() {+ assert_eq!(heading_letter("ấm", Some("vi")).as_deref(), Some("Â"));+ assert_eq!(heading_letter("Ánh", Some("vi")).as_deref(), Some("A"));+ assert_eq!(heading_letter("đường", Some("vi")).as_deref(), Some("Đ"));+ assert_eq!(heading_letter("ợ", Some("vi")).as_deref(), Some("Ơ")); assert_eq!(heading_letter("Łódź", Some("pl-PL")).as_deref(), Some("Ł")); assert_eq!(heading_letter("Łódź", Some("en-US")).as_deref(), Some("L")); assert_eq!(heading_letter("chata", Some("cs")).as_deref(), Some("Ch"));@@ -534,8 +688,10 @@ mod tests { #[test] fn hostile_input_never_panics() {- for s in ["", "\u{301}", "a\u{301}\u{301}\u{301}", "dzs", "\u{10FFFF}", "ẞ", "İstanbul"] {- for l in [None, Some("hu"), Some(""), Some("-"), Some("tr-TR")] {+ for s in+ ["", "\u{301}", "a\u{301}\u{301}\u{301}", "dzs", "\u{10FFFF}", "ẞ", "İstanbul", "\u{323}\u{302}", "ậ\u{301}\u{323}\u{309}", "đ\u{31b}"]+ {+ for l in [None, Some("hu"), Some(""), Some("-"), Some("tr-TR"), Some("vi")] { let _ = sort_key(s, l); let _ = heading_letter(s, l); }@@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Vietnamese sorting: `ă â đ ê ô ơ ư` as letters of their own and the five tones in dictionary order, #4317; previously minor: the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (adding a language, the embedded catalogs): `crates/ui-egui/src/i18n/mod.rs`. The catalogs, their format and@@ -165,7 +165,7 @@ Authorities. Letters with diacritics sort with their base letter (`é` with `e`) language makes them letters of their own: Polish `ą ć ę ł ń ó ś ź ż`, Czech/Slovak `č ch ř š ž` (and Slovak `ä ô`), Hungarian `cs dz dzs gy ly ny sz ty zs ö ü`, Swedish/Finnish `å ä ö` and Danish/Norwegian `æ ø å` after `z`, Spanish `ñ`, Turkish, Croatian/Bosnian/Serbian Latin,-Romanian, Estonian, Latvian, Lithuanian, Slovenian and Icelandic (`crates/doc/src/collate.rs`,+Romanian, Estonian, Latvian, Lithuanian, Slovenian, Icelandic and Vietnamese (`crates/doc/src/collate.rs`, a hand-written per-language alphabet table; full CLDR collation is not used). Table sort and table formulas read numbers with the language's decimal and group separators (`1 250,50` in Polish, `1.250,50` in German, `1,250.50` in English) and write formula results with its decimal@@ -176,6 +176,15 @@ formatting. Interface lists (styles, fonts) still sort by code point. Russian (`ru`) sorting keeps ё with е (a secondary diaeresis difference), before ж. Composed and decomposed ё and й have the same keys and index headings; the stored text is unchanged. Tests cover Russian locale aliases, case, representative words and these two canonical pairs. This is a narrow Cyrillic correction, not full Unicode normalization or CLDR collation. Ordering reference: [Unicode CLDR Russian collation chart](https://unicode.org/cldr/charts/42/collation/ru.html). +Vietnamese (`vi`) sorting follows its alphabet, `a ă â b c d đ e ê g h i k l m n o ô ơ p q r s t u ư v x y`+(#4317): `Ăn` and `Ẩn` follow `An`, `Đang` follows `Dung`. Tones are accents, compared only after+the letters, in dictionary order: none, grave (huyền), hook above (hỏi), tilde (ngã), acute (sắc),+dot below (nặng), so `ma < mà < mả < mã < má < mạ < mai`. Index headings drop the tone (`ấm` files+under `Â`). Composed and decomposed text (a toneless letter + combining tone, as Windows' "Unicode+tổ hợp" input writes, or full NFD) have the same keys. Other languages sort Vietnamese vowels+with their Latin letter (`Ẩn` under `A` before `B`) instead of after `z`. Ordering reference:+[Unicode CLDR Vietnamese collation chart](https://unicode.org/cldr/charts/42/collation/vi.html).+ ## Region conventions (#838) As in Word, a new blank document takes its paper from the system **region**, not the interface@@ -223,6 +232,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Vietnamese sorting (#4317): `ă â đ ê ô ơ ư` are letters of their own, tones compare in dictionary order after the letters, index headings drop the tone, decomposed text sorts like composed; other languages file Vietnamese vowels with their Latin letter instead of after `z` | | 2026-10-24 | minor | Preserved “Delete Around Caret in Equation” in all 27 shared WordCraft catalogs; the native/release pin and Flatpak source advance together. | | 2026-10-23 | minor | Interface catalogs moved to storytold/craft-i18n (`locales/<locale>/wordcraft.tsv`), looked up through craft-libs' `craft-i18n` crate and embedded from the pinned commit (`craft-i18n.rev`); strings other Crafting Apps already translate in the shared catalog now show translated; the "Open" paragraph-spacing preset no longer shows the file verb | | 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed |