collective

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

diff --git a/crates/doc/src/collate.rs b/crates/doc/src/collate.rs--- a/crates/doc/src/collate.rs+++ b/crates/doc/src/collate.rs@@ -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);             }diff --git a/docs/localization-parity.md b/docs/localization-parity.md--- a/docs/localization-parity.md+++ b/docs/localization-parity.md@@ -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 |