diff --git a/crates/ui-egui/src/dialogs/dialogs_layout/text.rs b/crates/ui-egui/src/dialogs/dialogs_layout/text.rs index f569b9cf9c..9fccba415c 100644 --- a/crates/ui-egui/src/dialogs/dialogs_layout/text.rs +++ b/crates/ui-egui/src/dialogs/dialogs_layout/text.rs @@ -322,7 +322,7 @@ impl LanguageForm { /// A language's name in the interface language (the tag itself when we don't know it). pub(super) fn language_name(tag: &str) -> String { - tl!(&crate::ribbon::language_name(Some(tag))).to_string() + crate::ribbon::display_name(Some(tag)) } pub(super) fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm) -> bool { diff --git a/crates/ui-egui/src/language_packs.rs b/crates/ui-egui/src/language_packs.rs index 9c2270dd4b..aad33c82b0 100644 --- a/crates/ui-egui/src/language_packs.rs +++ b/crates/ui-egui/src/language_packs.rs @@ -142,9 +142,8 @@ pub(crate) const MORE_LANGUAGES: [(&str, &str); 28] = [ /// the Language dialog name it; else the pack's own name; else its locale. pub(crate) fn language_of(pack_locale: &str, endonym: &str) -> String { let tag = crate::ribbon::tag_for_pack(pack_locale); - let named = crate::ribbon::language_name(Some(&tag)); - if !named.eq_ignore_ascii_case(&tag) { - return tl!(&named).to_string(); + if !crate::ribbon::language_name(Some(&tag)).eq_ignore_ascii_case(&tag) { + return crate::ribbon::display_name(Some(&tag)); } if endonym.is_empty() { pack_locale.to_string() } else { endonym.to_string() } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ad727435df..2b7e2b7b64 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -57,7 +57,7 @@ pub(crate) use language::sort_key; pub(crate) use language::{ caret_language, caret_language_proofed, caret_missing_dictionary, caret_no_proof, caret_unreadable_dictionary, language_menu, }; -pub(crate) use language::{language_list, language_name, same_language, tag_for_pack}; +pub(crate) use language::{display_name, language_list, language_name, same_language, tag_for_pack}; use layout::layout; #[cfg(test)] use layout::{columns_items, margins_items, orientation_items}; diff --git a/crates/ui-egui/src/ribbon/language.rs b/crates/ui-egui/src/ribbon/language.rs index 8815c0a89c..d2b482c2c5 100644 --- a/crates/ui-egui/src/ribbon/language.rs +++ b/crates/ui-egui/src/ribbon/language.rs @@ -96,7 +96,7 @@ pub(crate) fn short_languages() -> Vec<(String, String)> { more.push((tag, name)); } } - more.sort_by_cached_key(|(_, n)| sort_key(tl!(n))); + more.sort_by_cached_key(|(t, n)| sort_key(&display_of(t, n))); out.extend(more); out } @@ -113,7 +113,7 @@ pub(crate) fn all_languages() -> Vec<(String, String)> { }); for (tag, name) in builtin.chain(packs) { if !out.iter().any(|(t, _)| t.eq_ignore_ascii_case(&tag)) { - out.push((tag, tl!(&name).to_string())); + out.push((tag.clone(), display_of(&tag, &name))); } } out.sort_by_cached_key(|(_, n)| sort_key(n)); @@ -126,7 +126,7 @@ pub(crate) fn language_list(current: &str) -> Vec<(String, String)> { let mut list = all_languages(); // A tag the list has under another spelling (`nl` for `nl-NL`) is that row, not another. if !current.is_empty() && !list.iter().any(|(t, _)| same_language(current, t)) { - list.push((current.to_string(), tl!(&language_name(Some(current))).to_string())); + list.push((current.to_string(), display_name(Some(current)))); list.sort_by_cached_key(|(_, n)| sort_key(n)); } list @@ -170,15 +170,53 @@ pub(crate) fn sort_key(name: &str) -> (String, String) { (folded, lower) } +/// A language's name in English, as the lists build it: a whole name (`Polish`, `Serbian (Latin)`) +/// or a language with a region the lists have no entry for (`Swedish` and `Finland`). +enum NameParts { + Whole(String), + Regioned { language: String, region: String }, +} + /// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's /// default, US English): a listed language by its name; another region of one by the language /// and the region ("French (Canada)"); a bare language subtag by its usual variant; else the tag. pub(crate) fn language_name(tag: Option<&str>) -> String { + match name_parts(tag) { + NameParts::Whole(n) => n, + NameParts::Regioned { language, region } => format!("{language} ({region})"), + } +} + +/// [`language_name`] in the interface language: a whole name is looked up as it is, a language +/// with a region is the translated `{language} ({region})` of the translated language and region +/// (a name built at run time has no catalog row of its own). English is unchanged. +pub(crate) fn display_name(tag: Option<&str>) -> String { + display_name_with(tag, &|s| tl!(s).to_string()) +} + +/// [`display_name`] with the lookup given (`translate`: English in, interface language out). +pub(super) fn display_name_with(tag: Option<&str>, translate: &dyn Fn(&str) -> String) -> String { + match name_parts(tag) { + NameParts::Whole(n) => translate(&n), + NameParts::Regioned { language, region } => { + crate::i18n::fmt(&translate("{language} ({region})"), &[("language", &translate(&language)), ("region", &translate(®ion))]) + } + } +} + +/// The name a list shows for `tag` whose English name is `name`: [`display_name`] for the name +/// WordCraft has for the tag, else `name` as it is (a pack's own name for a language WordCraft +/// has none for). +pub(crate) fn display_of(tag: &str, name: &str) -> String { + if name == language_name(Some(tag)) { display_name(Some(tag)) } else { name.to_string() } +} + +fn name_parts(tag: Option<&str>) -> NameParts { let tag = tag.unwrap_or("en-US"); let named = |t: &str| known().find(|(l, _)| l.eq_ignore_ascii_case(t)).map(|(_, n)| *n); // Any language the Language dialog offers (French, German…) by its name, not its tag (#860). if let Some(name) = named(tag) { - return name.to_string(); + return NameParts::Whole(name.to_string()); } let mut parts = tag.split(['-', '_']); let primary = parts.next().unwrap_or(""); @@ -191,12 +229,12 @@ pub(crate) fn language_name(tag: Option<&str>) -> String { .find(|(l, _)| l.split('-').next().is_some_and(|p| p.eq_ignore_ascii_case(primary))) .map(|(_, n)| n.split(" (").next().unwrap_or(n)); match language { - Some(language) => format!("{language} ({})", region_name(r)), - None => tag.to_string(), + Some(language) => NameParts::Regioned { language: language.to_string(), region: region_name(r) }, + None => NameParts::Whole(tag.to_string()), } } // A bare language subtag ("pt", "pl"): the variant WordCraft proofs. - None => match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) { + None => NameParts::Whole(match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) { wordcraft_engine::proof::ProofLang::Pl => "Polish".into(), wordcraft_engine::proof::ProofLang::Hr => "Croatian".into(), wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(), @@ -213,7 +251,7 @@ pub(crate) fn language_name(tag: Option<&str>) -> String { _ => named_starting(tag) .or_else(|| known().find(|(t, _)| t.contains('-') && wordcraft_engine::packs::without_region(t).eq_ignore_ascii_case(tag))) .map_or_else(|| tag.to_string(), |(_, n)| (*n).to_string()), - }, + }), } } @@ -295,7 +333,7 @@ fn region_name(code: &str) -> String { /// The language at the caret (resolved through the styles), translated, for the status bar. pub(crate) fn caret_language(app: &WordApp) -> String { - tl!(&language_name(caret_lang_tag(app).as_deref())).to_string() + display_name(caret_lang_tag(app).as_deref()) } /// The formatting at the caret, resolved through the styles and the document default. @@ -349,11 +387,11 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) { let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false); let entries = short_languages(); let checks = language_checks(¤t, &entries); - if let Some(name) = checks.extra { - mi_check(ui, app, &name, true, "review.language", json!({"lang": current})); + if checks.extra.is_some() { + mi_check(ui, app, &display_name(Some(¤t)), true, "review.language", json!({"lang": current})); } for ((tag, name), on) in entries.iter().zip(checks.listed) { - mi_check(ui, app, name, on, "review.language", json!({"lang": tag})); + mi_check(ui, app, &display_of(tag, name), on, "review.language", json!({"lang": tag})); } ui.separator(); mi_check(ui, app, "Do Not Check Spelling or Grammar", no_proof, "review.language", json!({"noProof": !no_proof})); diff --git a/crates/ui-egui/src/ribbon/tests.rs b/crates/ui-egui/src/ribbon/tests.rs index e1c4221495..9ddb1e3272 100644 --- a/crates/ui-egui/src/ribbon/tests.rs +++ b/crates/ui-egui/src/ribbon/tests.rs @@ -308,6 +308,32 @@ fn rows_are_matched_by_tag_not_by_display_name() { assert_eq!(language_checks("pt", &[("pt-PT".to_string(), "Portuguese (Brazil)".to_string())]).listed, [false]); } +/// CP4.3 review: a name built from a language and a region (`Swedish (Finland)`) has no catalog row +/// of its own, so the language, the region and the `{language} ({region})` template are looked up +/// separately and joined. English stays as `language_name` builds it. +#[test] +fn a_language_and_region_name_is_translated_in_parts() { + use super::language::display_name_with; + let fake = |s: &str| -> String { + match s { + "Swedish" => "X".into(), + "Finland" => "Y".into(), + "{language} ({region})" => "{language} \u{2013} {region}".into(), + other => other.to_string(), + } + }; + assert_eq!(display_name_with(Some("sv-FI"), &fake), "X \u{2013} Y"); + // A whole name is looked up as it is; the template isn't used for it. + assert_eq!(display_name_with(Some("sv-SE"), &fake), "X"); + // Without a catalogue (English) the name is byte-identical to the English name. + for tag in ["sv-FI", "es-CU", "fr-CA", "de-CH", "pt-AO", "en-SG", "sr-Latn-BA", "pl-PL", "nl", "xx-YY", "xx", "ca-valencia"] { + assert_eq!(super::language::display_name(Some(tag)), language_name(Some(tag)), "{tag}"); + } + assert_eq!(language_name(Some("sv-FI")), "Swedish (Finland)"); + // Matching never uses a translated name: Finnish Swedish and Swedish of Sweden stay two rows. + assert!(!super::language::same_language("sv-FI", "sv-SE")); +} + /// #860 review: a tag the language list doesn't have shows by language and region, not as /// the tag; Portuguese (Angola) isn't Brazil's, and no name shows twice in the menu. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 43be96c165..adfe96a8cf 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -71,7 +71,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | | 7 | **Dialog depth**: Word-observed spelling/grammar task pane added (shared review commands, current-story traversal; see [spelling-review.md](spelling-review.md)); 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files with an AES header in the Word 97-2003 format** can't be opened: [MS-OFFCRYPTO] defines AES only for encrypted packages, so these are refused with a clear message. Encrypted packages open under any Word extension (`.docx`, `.doc`, `.dot`…) with either agile (Word 2010+) or Standard (Word 2007, AES-ECB) encryption; protected `.docx` saves with agile encryption (#55, #281, gap 9) | | `crates/docx/src/crypt.rs` (agile and Standard); `crates/docbin/src/crypt.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079). All synthetic fixtures; untested on Word-written files | Corporate and legal users with old files | 0 (refused by design; nothing left to build) | [file-format-parity.md](file-format-parity.md) | -| 8 | **Built-in proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; the other languages Word proofs get spelling and their own hyphenation only from an installed Language pack (without one their text is left unmarked and hyphenated with the English patterns; Bulgarian and German have their own), and none of them has grammar rules | | craft-proof (storytold/craft-libs `crates/proof`, data in craft-i18n `language-data/`) `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each checked against its own SCOWL 2026 dictionary and flagging the other's spellings, #1281, #40, #520), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #100 open. **Language packs** (CP4, 2026-11-01): a pack per locale (spelling dictionary, hyphenation, licence texts) installs from File › Options › Language or the missing-pack bar through the font store and is read at once (the bar offers a language before the list is fetched, and the caret's typing language counts); language-packs-1 (pinned, 61 packs) has Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707), Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438), Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947), French [#1325](https://github.com/storytold/wordcraft/issues/1325), Greek [#2561](https://github.com/storytold/wordcraft/issues/2561), Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806), Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438), with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian and the rest; Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505), German, Danish, Swedish, Norwegian, Hungarian and the other compounding languages are packs in language-packs-2 (craft-proof's flag-based compounding, storytold/craft-libs#497); Mongolian waits on faster suggestions, Thai on word segmentation, and Basque, Ukrainian and Uyghur are excluded on licence grounds (see [installing languages](spelling-dictionaries.md#language-packs)) | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | +| 8 | **Built-in proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; the other languages Word proofs get spelling and their own hyphenation only from an installed Language pack (without one their text is left unmarked and hyphenated with the English patterns; Bulgarian and German have their own), and none of them has grammar rules | | craft-proof (storytold/craft-libs `crates/proof`, data in craft-i18n `language-data/`) `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each checked against its own SCOWL 2026 dictionary and flagging the other's spellings, #1281, #40, #520), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #100 open. **Language packs** (CP4, 2026-11-01): a pack per locale (spelling dictionary, hyphenation, licence texts) installs from File › Options › Language or the missing-pack bar through the font store and is read at once; language-packs-1 (pinned, 61 packs) has Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707), Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438), Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947), French [#1325](https://github.com/storytold/wordcraft/issues/1325), Greek [#2561](https://github.com/storytold/wordcraft/issues/2561), Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806), Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438), with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian and the rest; Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505), German, Danish, Swedish, Norwegian, Hungarian and the other compounding languages are packs in language-packs-2 (craft-proof's flag-based compounding, storytold/craft-libs#497); Mongolian waits on faster suggestions, Thai on word segmentation, and Basque, Ukrainian and Uyghur are excluded on licence grounds (see [installing languages](spelling-dictionaries.md#language-packs)) | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 2 of the 12 key languages missing (hi, id) and pt-PT (Arabic landed with a mirrored shell, #1195; French landed, #729; Vietnamese, #1434; Korean, #1216; 28 interface languages in `crates/ui-egui/src/i18n`); open PRs for Indonesian (#572), pt-PT (#1028) and Hebrew with RTL (#20); ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 40–70 | [localization-parity.md](localization-parity.md) | | 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); two-stroke arrows (a line and a separate V or closed-triangle head) and one-stroke arrows with a closed triangle head landed (#1778); missing: Ink to Math, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) |