i18n: add French (fr) interface (#729)

* i18n: add French (fr) interface

A complete 1813-entry catalog with the same key set and order as
ru.tsv (the most complete reference), written from the meaning of the
English source. Registered in LANGUAGES after Turkish. The
locale-resolution tests that used fr-FR as the example of a language
without a catalog now assert the French mapping and use Italian,
which has no catalog, to keep testing the unsupported path.

* i18n: complete the French catalog and apply review fixes

Rebase onto main (1805 uk.tsv keys, up from 1812 rows at the original
PR): 357 new translations for the charts, Draw, content controls,
Caption/Index/TOC dialogs and formatting features that landed after
the PR was opened. Also applied the reviewer's five fixes: Bring
Forward → Avancer d'un niveau, Print Layout → Mode Page, Not used by
any command → N'est utilisé par aucune commande, Through → Au travers,
and consistent U+2019 apostrophes throughout.

* Review fixes for #729: attribution, French tests, language lists

- ATTRIBUTION row for fr.tsv, in the form the other catalogs use.
- French locale/saved-preference test and bundled-font coverage test (accents, œ, « », ’, and the
  no-break spaces in Inter).
- Tests that need a language without a catalog use a private-use tag (qaa-QM, qtz) instead of
  it-IT/de-DE, so they keep passing as real languages land.
- 11 French rows for commands that landed on main since (templates, styles organizer, Overtype,
  Move with Text), which every_tab_and_command_is_translated requires.
- fr in docs/control-protocol.md, README and localization-parity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review fixes for #729: French rows for Outline view and SmartArt

34 rows for the Outlining and SmartArt Design tabs and their commands, which landed on main
meanwhile and every_tab_and_command_is_translated requires; 1,850 entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* French: translate interface strings added on main since this PR

Adds 174 French entries (fr.tsv now covers every key in the other
catalogs, 2,023 entries) and drops the stale "Wildcards (regex)" row;
updates the documented entry count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* French: translate interface strings added on main since this PR

Adds the 10 strings main gained with Polish proofing (spelling and grammar
messages, Do Not Check Spelling or Grammar, comment/browse buttons): 2,033
entries; documented count updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* French: strings main added since the last merge (default app, numerals, KeyTips, Reading Mode)

---------

Co-authored-by: sanches <cfic34@ya.ru>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
This commit is contained in:
pechnikov2026-star
2026-10-12 06:38:53 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 sanches Brandon Thomas Brandon Thomas
parent 241877d666
commit feaff0f22b
7 changed files with 2151 additions and 21 deletions
+1
View File
@@ -70,3 +70,4 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog |
| `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker |
| `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog |
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog |
+1 -1
View File
@@ -117,7 +117,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski or Čeština (follows the system language by default) |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština or Français (follows the system language by default) |
| **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -52,7 +52,7 @@ pub struct LangInfo {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 16] = [
pub static LANGUAGES: [LangInfo; 17] = [
LangInfo { code: "en", name: "English", source: "", prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`).
LangInfo {
@@ -121,6 +121,8 @@ pub static LANGUAGES: [LangInfo; 16] = [
LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// Italian; `it-IT`, `it-CH` and `it-*` resolve here.
LangInfo { code: "it", name: "Italiano", source: include_str!("it.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// French; `fr`, `fr-FR`, `fr_CA` and other regions resolve here.
LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
];
impl LangInfo {
+77 -14
View File
@@ -22,7 +22,8 @@ fn system_locales_map_to_languages() {
assert_eq!(lang_from_tag("en-GB"), Some(Lang::EN));
assert_eq!(lang_from_tag("C"), Some(Lang::EN));
assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN));
assert_eq!(lang_from_tag("fr-FR"), None);
assert_eq!(lang_from_tag("fr-FR"), Some(lang("fr")));
assert_eq!(lang_from_tag(UNSUPPORTED), None);
assert_eq!(lang_from_tag(""), None);
assert_eq!(lang_from_tag("_"), None);
assert_eq!(lang_from_tag("sr"), Some(lang("sr")), "Cyrillic is the default script");
@@ -37,13 +38,19 @@ fn system_locales_map_to_languages() {
assert_eq!(lang_from_tag("DE_de.UTF-8@euro"), Some(lang("de")));
}
/// A language tag no catalog will ever have: `qaa`–`qtz` are reserved for local use (ISO 639-2),
/// so these tests keep working as real languages land.
const UNSUPPORTED: &str = "qaa-QM";
#[test]
fn the_first_supported_preferred_language_wins() {
// Windows and macOS list several preferred languages in order.
assert_eq!(first_supported(["fr-FR", "zh-Hant-TW", "en-US"]), Some(lang("zh-hant")));
assert_eq!(first_supported([UNSUPPORTED, "zh-Hant-TW", "en-US"]), Some(lang("zh-hant")));
assert_eq!(first_supported(["en-US", "ja-JP"]), Some(Lang::EN));
assert_eq!(first_supported(["fr-FR", "de-DE"]), Some(lang("de")));
assert_eq!(first_supported(["fr-FR", "nl-NL"]), None);
assert_eq!(first_supported([UNSUPPORTED, "qtz"]), None);
assert_eq!(first_supported(["fr-FR", "de-DE"]), Some(lang("fr")));
assert_eq!(first_supported(["nl-NL", "de-DE"]), Some(lang("de")));
assert_eq!(first_supported(["nl-NL", UNSUPPORTED]), None);
assert_eq!(first_supported([]), None);
}
@@ -171,7 +178,7 @@ fn ukrainian_locales_and_saved_preference_work_without_changing_the_document() {
for tag in ["uk", "uk-UA", "UK_ua.UTF-8", "uk-Cyrl-UA", "uk_UA.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(uk), "{tag}");
}
assert_eq!(first_supported(["fr-FR", "uk-UA", "en-US"]), Some(uk));
assert_eq!(first_supported([UNSUPPORTED, "uk-UA", "en-US"]), Some(uk));
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default());
let before = serde_json::to_value(&app.session.doc).unwrap();
let result = app.run("ui.language", serde_json::json!({"value": "UK"})).unwrap();
@@ -278,7 +285,7 @@ fn serbian_locales_resolve_by_script() {
for tag in ["sr-Latn", "sr-Latn-RS", "sr-latn-me"] {
assert_eq!(lang_from_tag(tag), Some(lat), "{tag}");
}
assert_eq!(first_supported(["fr-FR", "sr-Latn-RS", "en-US"]), Some(lat));
assert_eq!(first_supported([UNSUPPORTED, "sr-Latn-RS", "en-US"]), Some(lat));
assert_eq!(tr(cyr, "File"), "Фајл");
assert_eq!(tr(lat, "File"), "Fajl");
}
@@ -292,7 +299,7 @@ fn brazilian_portuguese_locales_and_saved_preference_work_without_changing_the_d
// European Portuguese and a bare `pt` have no catalog yet, so they follow the fallback.
assert_eq!(lang_from_tag("pt-PT"), None);
assert_eq!(lang_from_tag("pt"), None);
assert_eq!(first_supported(["fr-FR", "pt-BR", "en-US"]), Some(pt));
assert_eq!(first_supported([UNSUPPORTED, "pt-BR", "en-US"]), Some(pt));
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
let before = serde_json::to_value(&app.session.doc).unwrap();
let result = app.run("ui.language", serde_json::json!({"value": "PT-BR"})).unwrap();
@@ -353,7 +360,7 @@ fn czech_locales_and_saved_preference_work_without_changing_the_document() {
for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "CS", "cs-Latn-CZ", "cs_CZ.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(cs), "{tag}");
}
assert_eq!(first_supported(["fr-FR", "cs-CZ", "en-US"]), Some(cs));
assert_eq!(first_supported([UNSUPPORTED, "cs-CZ", "en-US"]), Some(cs));
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default());
let before = serde_json::to_value(&app.session.doc).unwrap();
let result = app.run("ui.language", serde_json::json!({"value": "CS"})).unwrap();
@@ -465,7 +472,7 @@ fn russian_locales_and_saved_preference_work_without_changing_the_document() {
for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "RU-ru", "ru_UA.UTF-8", "ru-Cyrl-RU"] {
assert_eq!(lang_from_tag(tag), Some(ru), "{tag}");
}
assert_eq!(first_supported(["fr-FR", "ru-RU", "en-US"]), Some(ru));
assert_eq!(first_supported([UNSUPPORTED, "ru-RU", "en-US"]), Some(ru));
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
let before = serde_json::to_value(&app.session.doc).unwrap();
let result = app.run("ui.language", serde_json::json!({"value": "RU"})).unwrap();
@@ -618,7 +625,7 @@ fn estonian_locales_and_saved_preference_keep_document_content() {
for tag in ["et", "et-EE", "ET_ee.UTF-8", "et-Latn-EE", "et_EE.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(et), "{tag}");
}
assert_eq!(first_supported(["fr-FR", "et-EE", "en-US"]), Some(et));
assert_eq!(first_supported([UNSUPPORTED, "et-EE", "en-US"]), Some(et));
assert_eq!(normalize_pref("ET"), Some("et"));
assert_eq!(et.name(), "Eesti");
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default());
@@ -669,7 +676,7 @@ fn turkish_locales_and_saved_preference_work_without_changing_the_document() {
for tag in ["tr", "tr-TR", "TR_tr.UTF-8", "tr-CY", "tr-Latn-TR", "tr_TR.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(tr), "{tag}");
}
assert_eq!(first_supported(["fr-FR", "tr-TR", "en-US"]), Some(tr));
assert_eq!(first_supported([UNSUPPORTED, "tr-TR", "en-US"]), Some(tr));
assert_eq!(normalize_pref("TR"), Some("tr"));
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default());
let before = serde_json::to_value(&app.session.doc).unwrap();
@@ -746,13 +753,69 @@ fn bundled_interface_fonts_cover_turkish_without_system_fallbacks() {
assert!(!lang("tr").uses_cjk());
}
#[test]
fn french_locales_and_saved_preference_work_without_changing_the_document() {
let fr = lang("fr");
for tag in ["fr", "fr-FR", "FR_fr.UTF-8", "fr-CA", "fr_BE.UTF-8@euro", "fr-CH", "fr-Latn-FR"] {
assert_eq!(lang_from_tag(tag), Some(fr), "{tag}");
}
assert_eq!(first_supported([UNSUPPORTED, "fr-CA", "en-US"]), Some(fr));
assert_eq!(normalize_pref("FR"), Some("fr"));
assert_eq!(fr.name(), "Français");
assert_eq!(tr(fr, "Home"), "Accueil");
assert_eq!(tr(fr, "Save"), "Enregistrer");
assert_eq!(fmt(tr(fr, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Page 3 sur 9");
assert_eq!(tr(fr, "unknown future label"), "unknown future label");
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default());
let before = serde_json::to_value(&app.session.doc).unwrap();
let result = app.run("ui.language", serde_json::json!({"value": "FR"})).unwrap();
assert_eq!(result["effective"], "fr");
assert_eq!(app.ui.language, "fr");
assert!(result["available"].as_array().unwrap().iter().any(|l| l["code"] == "fr" && l["name"] == "Français"));
assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);
let saved = serde_json::to_string(&app.ui).unwrap();
let restored: crate::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(Lang::from_pref(&restored.language), fr);
assert!(app.run("ui.language", serde_json::json!({"value": "fr-unknown"})).is_err());
assert_eq!(app.ui.language, "fr", "invalid preferences must keep the saved language");
app.run("ui.language", serde_json::json!({"value": "auto"})).unwrap();
assert_eq!(app.ui.language, AUTO);
}
#[test]
fn bundled_interface_fonts_cover_french_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("fr").0.source);
assert!(errors.is_empty());
// French letters and punctuation: accents, ligatures, guillemets and the typographic apostrophe.
let extra = "ÀàÂâÆæÇçÉéÈèÊêËëÎîÏïÔôŒœÙùÛûÜüŸÿ«»’";
let chars: std::collections::HashSet<char> =
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| ('\u{00C0}'..='\u{017F}').contains(c)).chain(extra.chars()).collect();
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
let face = db.face(family, style);
assert_eq!(face.family, family, "must use the bundled interface face");
for ch in &chars {
assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch:?}");
}
}
// The no-break spaces French puts before ; : ! ? and inside « » (the catalog uses plain
// spaces today) are in the interface faces too.
for style in ["Regular", "Medium", "SemiBold"] {
let face = db.face("Inter", style);
for ch in ['\u{00A0}', '\u{202F}'] {
assert_ne!(face.glyph_for(ch), 0, "Inter {style} lacks {ch:?}");
}
}
assert!(!lang("fr").uses_cjk());
}
#[test]
fn polish_locales_and_saved_preference_work_without_changing_the_document() {
let pl = lang("pl");
for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl", "pl_PL.UTF-8@euro", "pl-Latn-PL"] {
assert_eq!(lang_from_tag(tag), Some(pl), "{tag}");
}
assert_eq!(first_supported(["fr-FR", "pl-PL", "en-US"]), Some(pl));
assert_eq!(first_supported([UNSUPPORTED, "pl-PL", "en-US"]), Some(pl));
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default());
let before = serde_json::to_value(&app.session.doc).unwrap();
let result = app.run("ui.language", serde_json::json!({"value": "PL"})).unwrap();
@@ -859,7 +922,7 @@ fn german_locales_and_saved_preference_keep_document_content() {
for tag in ["de", "de-DE", "de-AT", "de-CH", "DE_de.UTF-8", "de_DE.UTF-8@euro", "de-Latn-DE"] {
assert_eq!(lang_from_tag(tag), Some(de), "{tag}");
}
assert_eq!(first_supported(["fr-FR", "de-AT", "en-US"]), Some(de));
assert_eq!(first_supported([UNSUPPORTED, "de-AT", "en-US"]), Some(de));
assert_eq!(normalize_pref("DE"), Some("de"));
assert_eq!(de.name(), "Deutsch");
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default());
@@ -910,7 +973,7 @@ fn italian_locales_and_saved_preference_keep_document_content() {
for tag in ["it", "it-IT", "it_IT.UTF-8", "it-CH", "it_IT.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(it), "{tag}");
}
assert_eq!(first_supported(["fr-FR", "it-IT", "en-US"]), Some(it));
assert_eq!(first_supported([UNSUPPORTED, "it-IT", "en-US"]), Some(it));
assert_eq!(normalize_pref("IT"), Some("it"));
assert_eq!(it.name(), "Italiano");
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default());
+1 -1
View File
@@ -50,7 +50,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
| Method | Params | Result |
|---|---|---|
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) |
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) |
| `engine.commands` | — | every command: id, label, location, shortcut, params, enabled |
| `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf |
+5 -4
View File
@@ -1,6 +1,6 @@
# Localization parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** 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-11 · **Change:** minor (French interface added; 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 (catalog
format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`.
@@ -19,8 +19,8 @@ native-speaker review (human).
## What WordCraft ships (measured)
- **16 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`,
Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl` and Czech `cs`. Catalogs have 2,021–2,399 entries
- **17 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`,
Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs` and French `fr`. Catalogs have 2,021–2,399 entries
each; Polish has every key of every other catalog (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command
label and ribbon location is translated in every language.
`pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically.
@@ -49,7 +49,7 @@ shared items below.
| Spanish | es | ~930; 98% / ~95% | most | Latin ✅ | no | partial | 1–2 |
| Hindi | hi | 0 | ❌ | Devanagari shaping via HarfRust, untested; no hyphenation | no | none | 4–6 |
| Arabic | ar | 0 | ❌ | shaping, joining, lam-alef, bidi ✅ (#207); no kashida; **no mirrored UI** | no | none | 6–8 |
| French | fr | 0 | ❌ | Latin ✅ | no | none | 3–4 |
| French | fr | 2,033 entries | most; newer dialogs partly English | Latin ✅ | no | partial | 1–2 |
| Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) |
| Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 |
| Japanese | ja | ~930; 98% / ~95% | most | CJK ✅, IME ✅; no vertical text, ruby or document grid | no | partial | 3–5 |
@@ -107,6 +107,7 @@ allows); all other text is written from the meaning of the English. No Italian p
| 2026-10-11 | minor | German interface catalog (#574): 2,021 entries covering the full zh-hans key set; locale, saved-preference and bundled-font tests; no German proofing |
| 2026-10-11 | minor | Italian interface catalog (#594), locale selection, saved preference and font coverage checks; count labels put the noun first so they read right at 1 |
| 2026-10-11 | minor | Font dialog sets the Asian text font (`w:eastAsia`) and the Latin text font separately; the Asian list holds the installed Chinese, Japanese and Korean fonts by their interface-language names (#505) |
| 2026-10-11 | minor | French interface catalog (2,033 entries, @pechnikov2026-star), `fr-*` system locales, saved preference and font coverage checks; no French proofing |
| 2026-10-11 | minor | Font list shows installed fonts by their names in the interface language (宋体 for SimSun in Chinese, Japanese and Korean names likewise) and finds them by either name; a Chinese, Japanese or Korean font is stored as `w:eastAsia` too (#505) |
| 2026-10-11 | minor | Chinese/Japanese interface fonts: a test checks every catalog character has a glyph; an installed CJK font follows the embedded faces whenever they miss one; macOS font asset folders searched for PingFang (#486) |
| 2026-10-10 | minor | Russian interface (#300; 1,524 entries, built-in style names shown translated; covers everything the interface audit finds) |