From 2d90f2d0719758635c994d70b491f3399f2cd622 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 22:20:55 +0100 Subject: [PATCH] feat(i18n): look interface strings up through the craft-i18n crate The i18n module becomes a thin wrapper over craft_i18n::I18n built from the embedded catalogs, offering exactly LANGUAGES (slimmed to code, prefer_hans, editing_lang; names come from @id language.name). WORDCRAFT_LANGUAGE and the sys-locale read stay; non-Brazilian pt-* stays English before the crate's matching (test). The pseudo-language uses the crate's Lang::PSEUDO and looked_up(). The Design tab's "Open" spacing preset uses get("spacing", "Open") so it never shows the file verb. The missing-translation report adds check_all() problems and a kept-English bucket and never fails; the untranslated-text audit still fails on text that skips tl!. Catalog-dependent tests skip with a note when nothing was embedded; a test keeps the flatpak commit equal to craft-i18n.rev. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/i18n/audit.rs | 26 ++- crates/ui-egui/src/i18n/mod.rs | 370 +++++++++++++----------------- crates/ui-egui/src/i18n/pseudo.rs | 23 +- crates/ui-egui/src/i18n/tests.rs | 271 +++++++++++++--------- crates/ui-egui/src/options.rs | 12 + crates/ui-egui/src/ribbon.rs | 12 + 6 files changed, 374 insertions(+), 340 deletions(-) diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 62fa2b434..95239eff7 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -343,14 +343,25 @@ fn untranslated_interface_text() { assert!(found.is_empty(), "{} interface texts skip translation (wrap them in tl!):\n{report}", found.len()); } -/// The missing-translation report (`cargo xtask i18n-report`, part of `cargo xtask ci`): prints, per -/// language, the interface strings its catalog has no entry for. A missing translation falls back -/// to English, so the report never fails. The strings are the ones the audit saw looked up, the +/// The missing-translation report (craft-i18n's S3; `cargo xtask i18n-report`, part of `cargo xtask +/// ci`): the embedded catalogs' problems (the crate's `check_all`), and per language the interface +/// strings with no translation (the missing bucket, which translators fill in craft-i18n) and those +/// whose translation is the English itself (kept English, not counted as translated). A missing +/// translation falls back to English, so the report never fails; without embedded catalogs +/// (`CRAFT_I18N_DIR` unset) it is skipped. The strings are the ones the audit saw looked up, the /// ribbon tabs, every command's label and ribbon location, the File › Home greetings, every reason /// a command gives for being off (#2678) and the equation gallery's labels (shown in menus the /// audit doesn't open one by one). Numbered style names go through a template. fn report_catalog_gaps(mut keys: std::collections::BTreeSet) { use wordcraft_engine::math_gallery::{BUILT_INS, STRUCTURES, SYMBOL_SETS}; + if super::CRAFT_I18N.is_empty() { + eprintln!("missing-translation report skipped: built without craft-i18n (set CRAFT_I18N_DIR to a craft-i18n checkout)"); + return; + } + let problems = super::I18N.check_all(); + if !problems.is_empty() { + eprintln!("{} catalog rows are skipped (they show in English):\n{problems:#?}", problems.len()); + } let tabs = crate::ribbon::TABS.iter().chain(&["Table Design", "Table Layout"]); keys.extend(tabs.map(|t| t.to_string())); keys.extend(["Good morning", "Good afternoon", "Good evening"].map(String::from)); @@ -407,10 +418,13 @@ fn report_catalog_gaps(mut keys: std::collections::BTreeSet) { // translate: its placeholder names aren't words the interface shows. let words = |s: &str| english_words(&s.split('{').map(|p| p.split_once('}').map_or(p, |(_, rest)| rest)).collect::()); for lang in super::Lang::all().filter(|l| *l != super::Lang::EN) { - let missing: Vec<&String> = - keys.iter().filter(|s| words(s) && !super::has(lang, s) && !numbered(s) && !same(s) && !script_error(s)).collect(); + let reported = keys.iter().filter(|s| words(s) && !numbered(s) && !same(s) && !script_error(s)); + let (kept, missing): (Vec<&String>, Vec<&String>) = reported.filter(|s| super::tr(lang, s) == s.as_str()).partition(|s| super::has(lang, s)); if !missing.is_empty() { - eprintln!("{} strings have no {} entry:\n{missing:#?}", missing.len(), lang.code()); + eprintln!("{} strings have no {} translation:\n{missing:#?}", missing.len(), lang.code()); + } + if !kept.is_empty() { + eprintln!("{} strings are kept in English in {}:\n{kept:#?}", kept.len(), lang.code()); } } } diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index edd9502f2..9f314e642 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -1,256 +1,216 @@ -//! Interface languages (#8). Strings in the code stay English and are the lookup keys; a catalog -//! per language (`*.tsv`, format in `zh-hans.tsv`) maps them to display text when the UI is drawn. -//! Command ids, document text, file names, the control channel, the CLI and MCP never see -//! translated text, so scripts and agents are unaffected. A string without a translation shows in -//! English, so coverage can grow one entry at a time. +//! Interface languages (#8). Strings in the code stay English and are the lookup keys; the +//! craft-i18n catalogs map them to display text when the UI is drawn. Command ids, document text, +//! file names, the control channel, the CLI and MCP never see translated text, so scripts and +//! agents are unaffected. A string without a translation shows in English, so coverage can grow +//! one entry at a time, and a missing translation never fails a build, a test or a release. +//! +//! The catalogs live in [storytold/craft-i18n](https://github.com/storytold/craft-i18n) +//! (`locales//common.tsv` and `wordcraft.tsv`), not in this repository. `build.rs` embeds +//! them from `CRAFT_I18N_DIR` (CI and releases check out the SHA in `craft-i18n.rev`); without it +//! the build is English only. Lookup, fallback (`pt-br` → `pt` → English, app rows over shared +//! ones), plurals, language names and locale matching are the `craft-i18n` crate's (craft-libs). //! //! The `language` setting is `auto` by default: the UI follows the operating system's language //! (Windows display language, macOS preferred languages, `LANG`/`LC_*` elsewhere) when there is a //! catalog for it, and English otherwise. File ▸ Options ▸ Interface language picks one by hand. //! -//! This is the system PdfCraft and PhotoCraft use (`pdfcraft/crates/ui-egui/src/i18n`), so the -//! catalog format and habits carry over between the Crafting Apps. +//! # Adding or changing a translation +//! In craft-i18n: add rows to `locales//wordcraft.tsv` (its `docs/integration.md`); they +//! show here once `craft-i18n.rev` moves to a commit that has them. A feature PR here adds the +//! English only. //! //! # Adding a language -//! 1. Add `xx.tsv` next to `zh-hans.tsv` (copy its header; translate from the *meaning* of the -//! English text, clean-room: never from Microsoft Word's or another product's localisation). +//! 1. craft-i18n gets `locales//wordcraft.tsv` and the language's `@id language.name` row. //! 2. Add one row to [`LANGUAGES`]. Set its `editing_lang` only when `wordcraft-proof` can proof //! the language (Polish: `pl-PL`): new documents are then written in it. //! -//! The Options dropdown, the system-language match and the catalog tests pick it up from there. +//! The Options dropdown and the system-language match pick it up from there. //! //! # Looking strings up //! - [`tl!`](crate::tl) / [`t`]: a string in the current language. [`tr`]: in a given one. //! - [`tc`] / [`trc`]: worded for a context (`preview`), for text that must fit a tight spot. +//! - [`get`]: one context's translation only, never the plain row (`spacing`'s "Open"). //! - [`location`]: a ribbon location such as `Home › Font`, segment by segment. //! - [`fmt`]: fill `{name}` placeholders after a lookup; translators may reorder them. -mod catalog; - use std::cell::Cell; -use std::sync::OnceLock; +use std::sync::{LazyLock, OnceLock}; -use catalog::Catalog; +use craft_i18n::I18n; + +pub use craft_i18n::fmt; + +include!(concat!(env!("OUT_DIR"), "/craft_i18n.rs")); + +/// The embedded catalogs, offering exactly the languages in [`LANGUAGES`]. Built on first use; +/// a locale is parsed on its first lookup. +static I18N: LazyLock = LazyLock::new(|| { + let offered: Vec<&str> = LANGUAGES.iter().map(|l| l.code).collect(); + let (i18n, problems) = I18n::new(CRAFT_I18N, &offered); + problems.iter().for_each(|p| log::warn!("interface catalogs: {p}")); + i18n +}); /// The `language` setting that follows the system. pub const AUTO: &str = "auto"; -/// One supported interface language. +/// One interface language WordCraft offers: what the app needs beyond the catalogs. Its name and +/// translations come from craft-i18n. pub struct LangInfo { - /// BCP 47 code, lowercase (`ja`, `zh-hans`). Also the `language` setting's value. + /// BCP 47 code, lowercase (`ja`, `zh-hans`). Also the `language` setting's value and the + /// craft-i18n locale. pub code: &'static str, - /// The language's name in itself, shown in the Options dropdown. - pub name: &'static str, - /// Catalog file contents (empty for the built-in English). - pub source: &'static str, /// Which CJK interface face comes first (Chinese text wants the Chinese face). pub prefer_hans: bool, /// The language (BCP 47) new blank documents are written in with this interface, for /// languages WordCraft can proof; `None` keeps WordCraft's default, US English. pub editing_lang: Option<&'static str>, - catalog: OnceLock, } -/// The registry. English first: it is the fallback and the source language. +/// The languages WordCraft offers, when their catalogs are embedded. English first: it is the +/// fallback and the source language. pub static LANGUAGES: [LangInfo; 27] = [ - 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 { - code: "zh-hans", - name: "简体中文", - source: include_str!("zh-hans.tsv"), - prefer_hans: true, - editing_lang: None, - catalog: OnceLock::new(), - }, + LangInfo { code: "en", prefer_hans: false, editing_lang: None }, + // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (the crate's matching). + LangInfo { code: "zh-hans", prefer_hans: true, editing_lang: None }, // Traditional Chinese (Taiwan vocabulary); `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` resolve here. - LangInfo { - code: "zh-hant", - name: "繁體中文", - source: include_str!("zh-hant.tsv"), - prefer_hans: true, - editing_lang: None, - catalog: OnceLock::new(), - }, - LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, - LangInfo { - code: "ko", - name: "한국어", - source: include_str!("ko.tsv"), - prefer_hans: false, // 일본어처럼 CJK지만 한자 우선순위는 필요 없으므로 false - editing_lang: None, // 현재 한국어 맞춤법(proofing) 지원이 없으므로 None - catalog: OnceLock::new(), - }, - LangInfo { - code: "uk", name: "Українська", source: include_str!("uk.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() - }, + LangInfo { code: "zh-hant", prefer_hans: true, editing_lang: None }, + LangInfo { code: "ja", prefer_hans: false, editing_lang: None }, + LangInfo { code: "ko", prefer_hans: false, editing_lang: None }, + LangInfo { code: "uk", prefer_hans: false, editing_lang: None }, // Spanish, neutral across Spain and Latin America; `es-ES`, `es-MX`, `es-419` … resolve here. - LangInfo { code: "es", name: "Español", source: include_str!("es.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { code: "es", prefer_hans: false, editing_lang: None }, // Brazilian Portuguese; `pt-BR` and `pt-BR-*` resolve here. Plain `pt` and `pt-PT` have no - // catalog yet (the European vocabulary differs), so they stay in English. - LangInfo { - code: "pt-br", - name: "Português (Brasil)", - source: include_str!("pt-br.tsv"), - prefer_hans: false, - editing_lang: Some("pt-BR"), - catalog: OnceLock::new(), - }, + // catalog yet (the European vocabulary differs), so they stay in English ([`lang_from_tag`]). + LangInfo { code: "pt-br", prefer_hans: false, editing_lang: Some("pt-BR") }, // Serbian, Cyrillic script (the default per BCP 47 when no script is given); `sr`, `sr-RS`, // `sr-Cyrl-*` resolve here. - LangInfo { code: "sr", name: "Српски", source: include_str!("sr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, - // Serbian, Latin script; `sr-Latn-*` resolves here (see `candidates`'s generic prefix - // matching — no special-casing needed, unlike Chinese's script-by-region fallback). - LangInfo { - code: "sr-latn", - name: "Srpski (latinica)", - source: include_str!("sr-latn.tsv"), - prefer_hans: false, - editing_lang: None, - catalog: OnceLock::new(), - }, + LangInfo { code: "sr", prefer_hans: false, editing_lang: None }, + // Serbian, Latin script; `sr-Latn-*` resolves here; a script is never + // crossed, so Latin Serbian never shows Cyrillic. + LangInfo { code: "sr-latn", prefer_hans: false, editing_lang: None }, // Polish; `pl`, `pl-PL` and `pl_PL.UTF-8` resolve here. WordCraft proofs Polish (spelling, // grammar, hyphenation), so new documents are written in Polish with this interface. - LangInfo { - code: "pl", - name: "Polski", - source: include_str!("pl.tsv"), - prefer_hans: false, - editing_lang: Some("pl-PL"), - catalog: OnceLock::new(), - }, - LangInfo { - code: "cs", - name: "Čeština", - source: include_str!("cs.tsv"), - prefer_hans: false, - editing_lang: Some("cs-CZ"), - catalog: OnceLock::new(), - }, - LangInfo { code: "et", name: "Eesti", source: include_str!("et.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { code: "pl", prefer_hans: false, editing_lang: Some("pl-PL") }, + LangInfo { code: "cs", prefer_hans: false, editing_lang: Some("cs-CZ") }, + LangInfo { code: "et", prefer_hans: false, editing_lang: None }, // German, neutral across Germany, Austria and Switzerland; `de`, `de-DE`, `de-AT`, `de-CH` … // resolve here. - LangInfo { code: "de", name: "Deutsch", source: include_str!("de.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { code: "de", prefer_hans: false, editing_lang: None }, // Russian; `ru`, `ru-RU`, `ru_RU.UTF-8` and other regions resolve here. - LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, - LangInfo { - code: "tr", - name: "Türkçe", - source: include_str!("tr.tsv"), - prefer_hans: false, - editing_lang: Some("tr-TR"), - catalog: OnceLock::new(), - }, + LangInfo { code: "ru", prefer_hans: false, editing_lang: None }, + LangInfo { code: "tr", prefer_hans: false, editing_lang: Some("tr-TR") }, // Italian; `it-IT`, `it-CH` and `it-*` resolve here. New documents are written in // Italian: `wordcraft-proof` checks it (`ProofLang::It`). - LangInfo { - code: "it", - name: "Italiano", - source: include_str!("it.tsv"), - prefer_hans: false, - editing_lang: Some("it-IT"), - catalog: OnceLock::new(), - }, + LangInfo { code: "it", prefer_hans: false, editing_lang: Some("it-IT") }, // Hungarian; `hu`, `hu-HU` and `hu_HU.UTF-8` resolve here. - LangInfo { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { code: "hu", prefer_hans: false, editing_lang: None }, // 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() }, + LangInfo { code: "fr", prefer_hans: false, editing_lang: None }, // Bulgarian; `bg`, `bg-BG` and `bg_BG.UTF-8` resolve here. - LangInfo { - code: "bg", name: "Български", source: include_str!("bg.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() - }, + LangInfo { code: "bg", prefer_hans: false, editing_lang: None }, // Vietnamese; `vi`, `vi-VN` and `vi_VN.UTF-8` resolve here. - LangInfo { code: "vi", name: "Tiếng Việt", source: include_str!("vi.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { code: "vi", prefer_hans: false, editing_lang: None }, // Swedish; sv, sv-SE, sv-FI and sv_SE.UTF-8 resolve here. - LangInfo { code: "sv", name: "Svenska", source: include_str!("sv.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { code: "sv", prefer_hans: false, editing_lang: None }, // Croatian; `hr`, `hr-HR` and `hr_HR.UTF-8` resolve here. - LangInfo { code: "hr", name: "Hrvatski", source: include_str!("hr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { code: "hr", prefer_hans: false, editing_lang: None }, // Dutch; `nl`, `nl-NL`, `nl-BE` and `nl_NL.UTF-8` resolve here. - LangInfo { code: "nl", name: "Nederlands", source: include_str!("nl.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { code: "nl", prefer_hans: false, editing_lang: None }, // Danish; `da`, `da-DK` and `da_DK.UTF-8` resolve here. - LangInfo { code: "da", name: "Dansk", source: include_str!("da.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { code: "da", prefer_hans: false, editing_lang: None }, // Romanian; `ro`, `ro-RO`, `ro_RO.UTF-8` and `ro-MD` resolve here. - LangInfo { code: "ro", name: "Română", source: include_str!("ro.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { code: "ro", prefer_hans: false, editing_lang: None }, // Slovenian; `sl`, `sl-SI` and `sl_SI.UTF-8` resolve here. - LangInfo { code: "sl", name: "Slovenščina", source: include_str!("sl.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { code: "sl", prefer_hans: false, editing_lang: None }, ]; -impl LangInfo { - fn catalog(&self) -> &Catalog { - self.catalog.get_or_init(|| { - let (catalog, errors) = Catalog::parse(self.source); - for error in errors { - log::warn!("{} interface catalog: {error}; that entry shows in English", self.code); - } - catalog - }) - } -} - -/// A language the UI can be shown in (a handle into [`LANGUAGES`]). +/// A language the UI can be shown in: English, or an offered language whose catalogs are embedded. #[derive(Clone, Copy)] -pub struct Lang(&'static LangInfo); +pub struct Lang { + lang: craft_i18n::Lang, + /// The catalogs it is looked up in: the embedded ones ([`I18N`]), or a test fixture's. + catalogs: Option<&'static I18n>, +} impl std::fmt::Debug for Lang { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Lang({})", self.0.code) + write!(f, "Lang({})", self.code()) } } impl PartialEq for Lang { fn eq(&self, other: &Self) -> bool { - self.0.code == other.0.code + self.code() == other.code() } } impl Eq for Lang {} impl Lang { - pub const EN: Lang = Lang(&LANGUAGES[0]); + pub const EN: Lang = Lang { lang: craft_i18n::Lang::EN, catalogs: None }; - pub fn code(self) -> &'static str { - self.0.code + fn i18n(self) -> &'static I18n { + self.catalogs.unwrap_or_else(|| LazyLock::force(&I18N)) } + fn info(self) -> Option<&'static LangInfo> { + let code = self.code(); + LANGUAGES.iter().find(|l| l.code == code) + } + + pub fn code(self) -> &'static str { + self.i18n().code(self.lang) + } + + /// The language's name in itself (its `@id language.name` row), shown in the Options dropdown. pub fn name(self) -> &'static str { - self.0.name + self.i18n().name(self.lang) } pub fn prefers_hans(self) -> bool { - self.0.prefer_hans + self.info().is_some_and(|l| l.prefer_hans) } pub fn prefers_korean(self) -> bool { - self.0.code == "ko" + self.code() == "ko" } /// The language new blank documents are written in with this interface ([`LangInfo::editing_lang`]). pub fn editing_lang(self) -> Option<&'static str> { - self.0.editing_lang + self.info().and_then(|l| l.editing_lang) } /// Is the interface written in CJK script (its own name is), so it needs a CJK face? pub fn uses_cjk(self) -> bool { - self.0.name.chars().any(|c| ('\u{2E80}'..='\u{9FFF}').contains(&c) || ('\u{AC00}'..='\u{D7AF}').contains(&c)) + self.name().chars().any(|c| ('\u{2E80}'..='\u{9FFF}').contains(&c) || ('\u{AC00}'..='\u{D7AF}').contains(&c)) + } + + /// The embedded catalogs (shared, then WordCraft's) this language reads; none for English. + fn catalog_texts(self) -> impl Iterator { + let code = self.code(); + CRAFT_I18N.iter().filter(move |(c, ..)| c.eq_ignore_ascii_case(code)).flat_map(|(_, shared, app)| [*shared, *app]) } /// The CJK interface text the fonts must cover with the Chinese (`hans`) or other CJK fallback /// order: the catalogs of the CJK languages using that order, and every language's own name (the /// Options list). The interface fonts check the embedded faces against it (#486). pub fn cjk_font_text(hans: bool) -> (Vec<&'static str>, String) { - let catalogs = Lang::all().filter(|l| l.uses_cjk() && l.prefers_hans() == hans).map(|l| l.0.source).collect(); + let catalogs = Lang::all().filter(|l| l.uses_cjk() && l.prefers_hans() == hans).flat_map(Lang::catalog_texts).collect(); (catalogs, Lang::all().map(Lang::name).collect::>().join(" ")) } /// The CJK interface catalogs using the Korean or Chinese writing-system preference. pub fn cjk_font_text_for(hans: bool, korean: bool) -> (Vec<&'static str>, String) { - let catalogs = Lang::all().filter(|l| l.uses_cjk() && l.prefers_hans() == hans && l.prefers_korean() == korean).map(|l| l.0.source).collect(); + let catalogs = + Lang::all().filter(|l| l.uses_cjk() && l.prefers_hans() == hans && l.prefers_korean() == korean).flat_map(Lang::catalog_texts).collect(); (catalogs, Lang::all().map(Lang::name).collect::>().join(" ")) } - /// A language by its exact code (any case). + /// A language by its exact code (any case): one of [`LANGUAGES`] whose catalogs are embedded. pub fn from_code(code: &str) -> Option { - LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang) + let info = LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code))?; + I18N.from_code(info.code).map(|lang| Lang { lang, catalogs: None }) } /// Resolve the `language` setting: a language code, or [`AUTO`] (and anything unknown, e.g. a @@ -259,9 +219,10 @@ impl Lang { Lang::from_code(pref).unwrap_or_else(system_lang) } - /// Every registered language. + /// Every language the interface can be shown in: [`LANGUAGES`] in order, those with embedded + /// catalogs (English only in a build without craft-i18n). pub fn all() -> impl Iterator { - LANGUAGES.iter().map(Lang) + LANGUAGES.iter().filter_map(|l| Lang::from_code(l.code)) } /// Every registered language in the order a picker lists them: English first (the source @@ -273,10 +234,6 @@ impl Lang { rest.sort_by_cached_key(|l| name_sort_key(l.name())); std::iter::once(Lang::EN).chain(rest).collect() } - - fn catalog(self) -> &'static Catalog { - self.0.catalog() - } } /// The canonical `language` setting for user input: [`AUTO`] or a registered code (any case). @@ -288,31 +245,22 @@ pub fn normalize_pref(pref: &str) -> Option<&'static str> { Lang::from_code(pref).map(Lang::code) } -/// Candidate codes for a locale tag, most specific first: `zh_TW.UTF-8` → `zh-tw`, `zh-hant`, `zh`. -fn candidates(tag: &str) -> Vec { - let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-").to_ascii_lowercase(); - let parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).collect(); - let Some(&primary) = parts.first() else { return Vec::new() }; - let mut out = Vec::new(); - for n in (1..=parts.len()).rev() { - out.push(parts.get(..n).unwrap_or_default().join("-")); - } - if primary == "zh" && !parts.iter().any(|p| matches!(*p, "hans" | "hant")) { - // Chinese by region when no script is given. - let script = if parts.iter().any(|p| matches!(*p, "tw" | "hk" | "mo")) { "zh-hant" } else { "zh-hans" }; - out.insert(out.len().saturating_sub(1), script.to_string()); - } - out +/// Portuguese other than Brazil's (`pt`, `pt-PT`, `pt_AO.UTF-8`): WordCraft has no catalog for +/// it (the European vocabulary differs), so it stays English rather than falling to `pt-br` as the +/// crate's matching would. +fn non_brazilian_portuguese(tag: &str) -> bool { + let tag = tag.trim().to_ascii_lowercase(); + let mut parts = tag.split(['.', '@']).next().unwrap_or("").split(['-', '_']); + parts.next() == Some("pt") && !parts.any(|p| p == "br") } /// The registered language for a locale tag such as `ja_JP.UTF-8` or `zh-Hant-TW`; `None` when /// there is no catalog for it. `C`/`POSIX` mean English. pub fn lang_from_tag(tag: &str) -> Option { - let cands = candidates(tag); - if matches!(cands.first().map(String::as_str), Some("c" | "posix")) { - return Some(Lang::EN); + if non_brazilian_portuguese(tag) { + return None; } - cands.iter().find_map(|c| Lang::from_code(c)) + I18N.match_tag(tag).and_then(|lang| Lang::from_code(I18N.code(lang))) } /// The first of the system's preferred languages WordCraft has, in the user's order: someone @@ -368,9 +316,9 @@ pub fn current() -> Lang { CURRENT.get() } -/// Does `lang` have a catalog entry for this string? (English never does: it is the source.) +/// Does `lang` have a translation for this string? (English never does: it is the source.) pub fn has(lang: Lang, s: &str) -> bool { - lang.catalog().plain(s).is_some() + lang.i18n().get(lang.lang, "", s).is_some() } /// `s` in the current language ([`tr`] with [`current`]). @@ -378,8 +326,8 @@ pub fn t(s: &str) -> &str { tr(current(), s) } -/// `s` in the current language as worded for `context` (one of `catalog::CONTEXTS`), falling -/// back to the plain translation and then to English. +/// `s` in the current language as worded for `context` (`preview`, `template`), falling back to +/// the plain translation and then to English. pub fn tc<'a>(context: &str, s: &'a str) -> &'a str { trc(current(), context, s) } @@ -388,9 +336,21 @@ pub fn tc<'a>(context: &str, s: &'a str) -> &'a str { pub fn trc<'a>(lang: Lang, context: &str, s: &'a str) -> &'a str { #[cfg(test)] if pseudo::on() { - return pseudo::mark(s); + return I18N.tc(craft_i18n::Lang::PSEUDO, context, s); } - lang.catalog().in_context(context, s).unwrap_or(s) + lang.i18n().tc(lang.lang, context, s) +} + +/// `s`'s translation for `context` in the current language, with no fallback to the plain row +/// or English: for a word whose plain translation means something else here (the "Open" +/// paragraph-spacing preset is not the file verb). +pub fn get<'a>(context: &str, s: &'a str) -> Option<&'a str> { + #[cfg(test)] + if pseudo::on() { + return I18N.get(craft_i18n::Lang::PSEUDO, context, s); + } + let lang = current(); + lang.i18n().get(lang.lang, context, s) } /// Lowercase translated interface labels and UI search queries in the current language. @@ -416,9 +376,9 @@ pub fn lowercase(s: &str) -> String { pub fn tr(lang: Lang, s: &str) -> &str { #[cfg(test)] if pseudo::on() { - return pseudo::mark(s); + return I18N.t(craft_i18n::Lang::PSEUDO, s); } - lang.catalog().plain(s).unwrap_or(s) + lang.i18n().t(lang.lang, s) } /// A style's name as the interface shows it: built-in styles (`Normal`, `Heading 2`, `TOC 3`) in @@ -460,38 +420,26 @@ pub fn prefixed(prefix: &str, rest: &str) -> String { fmt(t(&format!("{prefix} {{action}}")), &[("action", t(rest))]) } -/// Fill `{name}` placeholders in one pass. Unknown placeholders stay as written, and inserted -/// values are never read as templates again, so a file name containing `{n}` stays intact. -pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { - let mut out = String::with_capacity(template.len()); - let mut rest = template; - while let Some((before, after_open)) = rest.split_once('{') { - out.push_str(before); - let Some((name, after_close)) = after_open.split_once('}') else { - out.push('{'); - out.push_str(after_open); - return out; - }; - match args.iter().find(|(key, _)| *key == name) { - Some((_, value)) => out.push_str(value), - None => { - out.push('{'); - out.push_str(name); - out.push('}'); - } - } - rest = after_close; - } - out.push_str(rest); - out -} - -/// A test language whose catalog is `source` (catalog format), so tests of lookup code don't -/// depend on the bundled catalogs' wording. `code` selects code-specific rules such as Turkish -/// casing. +/// A test language whose WordCraft catalog is `source` (catalog format), so tests of lookup code +/// don't depend on the embedded catalogs' wording. `code` selects code-specific rules such as +/// Turkish casing. #[cfg(test)] pub(crate) fn fixture_lang(code: &'static str, source: &'static str) -> Lang { - Lang(Box::leak(Box::new(LangInfo { code, name: "Fixture", source, prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }))) + let embedded: &'static craft_i18n::Embedded = Box::leak(Box::new([(code, "", source)])); + let (i18n, _) = I18n::new(embedded, &[code]); + let i18n: &'static I18n = Box::leak(Box::new(i18n)); + let lang = i18n.from_code(code).unwrap_or(craft_i18n::Lang::EN); + Lang { lang, catalogs: Some(i18n) } +} + +/// For a test that needs the embedded catalogs: `false`, with a note, when none were embedded +/// (`CRAFT_I18N_DIR` unset), so the test can skip; the interface is then English only. +#[cfg(test)] +pub(crate) fn catalogs_embedded() -> bool { + if CRAFT_I18N.is_empty() { + eprintln!("skipped: built without craft-i18n (CRAFT_I18N_DIR); the interface is English only"); + } + !CRAFT_I18N.is_empty() } #[cfg(test)] diff --git a/crates/ui-egui/src/i18n/pseudo.rs b/crates/ui-egui/src/i18n/pseudo.rs index 63165c59c..8a3ad9281 100644 --- a/crates/ui-egui/src/i18n/pseudo.rs +++ b/crates/ui-egui/src/i18n/pseudo.rs @@ -1,20 +1,18 @@ -//! A test-only pseudo-language: every string that goes through the catalog lookup comes back -//! wrapped in `⟦…⟧`, so text drawn without the marks never went through translation. +//! A test-only pseudo-language: while it is on (per thread), every string that goes through a +//! lookup comes back as the craft-i18n crate's pseudo locale marks it, wrapped in `⟦…⟧`, so text +//! drawn without the marks never went through translation. -use std::cell::{Cell, RefCell}; -use std::collections::{BTreeSet, HashMap}; +use std::cell::Cell; +use std::collections::BTreeSet; thread_local! { static ON: Cell = const { Cell::new(false) }; - // Marked strings live for the test process; lookups hand out `&'static str`. - static CACHE: RefCell> = RefCell::new(HashMap::new()); - // Every English string looked up while on, to check against a real catalog. - static LOOKED_UP: RefCell> = const { RefCell::new(BTreeSet::new()) }; } -/// Every English string looked up while the pseudo-language was on. +/// Every English string looked up in the pseudo-language so far, in this process (the crate +/// keeps one list for every thread). pub fn looked_up() -> BTreeSet { - LOOKED_UP.with_borrow(Clone::clone) + super::I18N.looked_up() } pub fn on() -> bool { @@ -25,11 +23,6 @@ pub fn set(on: bool) { ON.set(on); } -pub fn mark(s: &str) -> &'static str { - LOOKED_UP.with_borrow_mut(|l| l.insert(s.to_string())); - CACHE.with_borrow_mut(|c| *c.entry(s.to_string()).or_insert_with(|| Box::leak(format!("⟦{}⟧", s.replace('\n', "⟧\n⟦")).into_boxed_str()))) -} - /// `text` without its marked segments. Lines are marked one by one, since multi-line labels are /// often drawn a line at a time. pub fn unmarked(text: &str) -> String { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 340b9d49a..c05e465f7 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1,14 +1,39 @@ //! Interface language tests (#8). -use super::catalog::{Catalog, parse_entries, placeholders}; use super::*; fn lang(code: &str) -> Lang { Lang::from_code(code).unwrap_or_else(|| panic!("{code} registered")) } +/// Skips a test that needs the embedded catalogs when none were embedded (`CRAFT_I18N_DIR` +/// unset): the interface is then English only. +macro_rules! needs_catalogs { + () => { + if !catalogs_embedded() { + return; + } + }; +} + +/// The translations in `code`'s embedded WordCraft catalog, plural forms apart: the fonts must +/// cover WordCraft's own wording. (The shared catalog holds every app's; the CJK checks read it too.) +fn translations(code: &str) -> Vec { + let code = lang(code).code(); + CRAFT_I18N + .iter() + .filter(|(c, ..)| *c == code) + .flat_map(|(_, _, app)| app.lines()) + .filter(|l| !l.starts_with('#')) + .filter_map(|l| l.split('\t').nth(2)) + .flat_map(|t| t.split('|')) + .map(String::from) + .collect() +} + #[test] fn system_locales_map_to_languages() { + needs_catalogs!(); assert_eq!(lang_from_tag("zh_CN.UTF-8"), Some(lang("zh-hans"))); assert_eq!(lang_from_tag("zh-CN"), Some(lang("zh-hans"))); assert_eq!(lang_from_tag("zh"), Some(lang("zh-hans"))); @@ -46,6 +71,7 @@ const UNSUPPORTED: &str = "qaa-QM"; #[test] fn the_first_supported_preferred_language_wins() { + needs_catalogs!(); // Windows and macOS list several preferred languages in order. 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)); @@ -58,6 +84,7 @@ fn the_first_supported_preferred_language_wins() { #[test] fn settings_resolve_with_fallback() { + needs_catalogs!(); assert_eq!(Lang::from_pref("ja"), lang("ja")); assert_eq!(Lang::from_pref("ZH-HANS"), lang("zh-hans")); assert_eq!(Lang::from_pref("en"), Lang::EN); @@ -79,16 +106,6 @@ fn lookups_fall_back_to_english() { assert!(has(fx, "File") && !has(fx, "Save") && !has(Lang::EN, "File")); } -/// Each registered language looks strings up in its own catalog file. -#[test] -fn each_language_reads_its_own_catalog() { - for l in Lang::all() { - for e in parse_entries(l.0.source).0.iter().filter(|e| e.context.is_empty()) { - assert_eq!(tr(l, &e.source), e.translation, "{}", l.code()); - } - } -} - #[test] fn current_language_is_per_thread() { set_current(fixture_lang( @@ -117,35 +134,9 @@ fn placeholders_fill_without_reinterpreting_values() { assert_eq!(fmt(tr(fx, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "9: 3"); } -#[test] -fn malformed_catalog_lines_are_skipped_not_fatal() { - let (c, errors) = - Catalog::parse("# c\n\tHello\t你好\nctx\tA\tB\n\tOpen…\t打开\n\tPage {n}\t第 {m} 页\nno tabs\n\tHello\t重复\n\tLine\\nTwo\t一\\n二\n"); - assert_eq!(errors.len(), 5, "{errors:?}"); - assert_eq!(c.plain("Hello"), Some("你好"), "the first entry wins"); - assert_eq!(c.plain("Line\nTwo"), Some("一\n二")); - assert_eq!(c.plain("Open…"), None); -} - -/// Every bundled catalog parses without errors, and its entries keep the English placeholders. -#[test] -fn bundled_catalogs_are_well_formed() { - let mut codes = std::collections::HashSet::new(); - for l in &LANGUAGES { - assert!(codes.insert(l.code), "duplicate code {}", l.code); - assert_eq!(l.code, l.code.to_ascii_lowercase()); - let (entries, errors) = parse_entries(l.source); - assert!(errors.is_empty(), "{}: {errors:?}", l.code); - for e in &entries { - assert_eq!(placeholders(&e.source), placeholders(&e.translation), "{}: {:?}", l.code, e.source); - assert_eq!(e.translation.trim(), e.translation, "{}: stray whitespace in {:?}", l.code, e.translation); - assert!(!e.translation.contains("..."), "{}: use … rather than three dots: {:?}", l.code, e.translation); - } - } -} - #[test] fn the_language_setting_is_a_ui_command_and_persists() { + needs_catalogs!(); let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); assert_eq!(app.ui.language, AUTO); let r = app.run("ui.language", serde_json::json!({"value": "ZH-HANT"})).unwrap(); @@ -168,6 +159,7 @@ fn the_language_setting_is_a_ui_command_and_persists() { #[test] fn ukrainian_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); let uk = lang("uk"); 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}"); @@ -188,10 +180,10 @@ fn ukrainian_locales_and_saved_preference_work_without_changing_the_document() { #[test] fn bundled_interface_fonts_cover_ukrainian_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); - let (entries, errors) = parse_entries(lang("uk").0.source); - assert!(errors.is_empty()); + needs_catalogs!(); + let entries = translations("uk"); let chars: std::collections::HashSet = - entries.iter().flat_map(|e| e.translation.chars()).filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).chain("ҐґЄєІіЇї".chars()).collect(); + entries.iter().flat_map(|e| e.chars()).filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).chain("ҐґЄєІіЇї".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"); @@ -204,10 +196,9 @@ fn bundled_interface_fonts_cover_ukrainian_without_system_fallbacks() { #[test] fn bundled_interface_fonts_cover_serbian_cyrillic_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); - let (entries, errors) = parse_entries(lang("sr").0.source); - assert!(errors.is_empty()); - let chars: std::collections::HashSet = - entries.iter().flat_map(|e| e.translation.chars()).filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).collect(); + needs_catalogs!(); + let entries = translations("sr"); + let chars: std::collections::HashSet = entries.iter().flat_map(|e| e.chars()).filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).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"); @@ -220,12 +211,11 @@ fn bundled_interface_fonts_cover_serbian_cyrillic_without_system_fallbacks() { #[test] fn bundled_interface_fonts_cover_serbian_latin_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); - let (entries, errors) = parse_entries(lang("sr-latn").0.source); - assert!(errors.is_empty()); + needs_catalogs!(); + let entries = translations("sr-latn"); // Serbian Latin's letters beyond plain ASCII: š č ć ž đ (and their capitals). let extra = ['š', 'č', 'ć', 'ž', 'đ', 'Š', 'Č', 'Ć', 'Ž', 'Đ']; - let chars: std::collections::HashSet = - entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(c)).chain(extra).collect(); + let chars: std::collections::HashSet = entries.iter().flat_map(|e| e.chars()).filter(|c| extra.contains(c)).chain(extra).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"); @@ -237,6 +227,7 @@ fn bundled_interface_fonts_cover_serbian_latin_without_system_fallbacks() { #[test] fn serbian_locales_resolve_by_script() { + needs_catalogs!(); let cyr = lang("sr"); let lat = lang("sr-latn"); for tag in ["sr", "sr-RS", "sr_RS.UTF-8", "SR", "sr-Cyrl", "sr-Cyrl-RS"] { @@ -250,13 +241,17 @@ fn serbian_locales_resolve_by_script() { #[test] fn brazilian_portuguese_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); let pt = lang("pt-br"); for tag in ["pt-BR", "pt_BR.UTF-8", "PT-BR", "pt-BR-latn", "pt_BR.UTF-8@euro"] { assert_eq!(lang_from_tag(tag), Some(pt), "{tag}"); } - // 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); + // European Portuguese and a bare `pt` have no catalog yet, so they follow the fallback + // rather than the crate's `pt-br` match. + for tag in ["pt-PT", "pt", "pt_PT.UTF-8", "pt-AO", "PT_pt"] { + assert_eq!(lang_from_tag(tag), None, "{tag}"); + } + assert_eq!(first_supported(["pt-PT", "fr-FR"]), Some(lang("fr"))); 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(); @@ -273,11 +268,11 @@ fn brazilian_portuguese_locales_and_saved_preference_work_without_changing_the_d #[test] fn bundled_interface_fonts_cover_brazilian_portuguese_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); - let (entries, errors) = parse_entries(lang("pt-br").0.source); - assert!(errors.is_empty()); + needs_catalogs!(); + let entries = translations("pt-br"); let chars: std::collections::HashSet = entries .iter() - .flat_map(|e| e.translation.chars()) + .flat_map(|e| e.chars()) // The accented Latin letters Portuguese adds on top of ASCII; symbols such as ¶, ⌘ or 📂 // come from the English sources and render as they already do. .filter(|c| ('\u{00C0}'..='\u{017F}').contains(c)) @@ -294,6 +289,7 @@ fn bundled_interface_fonts_cover_brazilian_portuguese_without_system_fallbacks() #[test] fn czech_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); let cs = lang("cs"); 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}"); @@ -314,11 +310,11 @@ fn czech_locales_and_saved_preference_work_without_changing_the_document() { #[test] fn bundled_interface_fonts_cover_czech_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); - let (entries, errors) = parse_entries(lang("cs").0.source); - assert!(errors.is_empty()); + needs_catalogs!(); + let entries = translations("cs"); let chars: std::collections::HashSet = entries .iter() - .flat_map(|e| e.translation.chars()) + .flat_map(|e| e.chars()) .filter(|c| ('\u{00C0}'..='\u{017F}').contains(c)) .chain("ÁáČčĎďÉéĚěÍíŇňÓóŘřŠšŤťÚúŮůÝýŽž".chars()) .collect(); @@ -333,6 +329,7 @@ fn bundled_interface_fonts_cover_czech_without_system_fallbacks() { #[test] fn chinese_interface_fonts_load_and_cover_simplified_hanzi() { + needs_catalogs!(); // #241: without an embedded Chinese face the interface adds an installed CJK font; egui must // accept it (it panics on font data it can't parse) and draw simplified-only hanzi with it. let ctx = egui::Context::default(); @@ -348,6 +345,7 @@ fn chinese_interface_fonts_load_and_cover_simplified_hanzi() { #[test] fn korean_interface_fonts_load_and_cover_hangul() { + needs_catalogs!(); let ctx = egui::Context::default(); ctx.set_fonts(crate::theme::font_definitions_language(false, true, true)); ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); @@ -372,24 +370,26 @@ fn cjk_catalogs_are_covered_by_the_embedded_interface_faces() { eprintln!("skipped: built without craft-fonts (CRAFT_FONTS_DIR)"); return; } + needs_catalogs!(); let names: String = Lang::all().map(Lang::name).collect(); for code in ["zh-hans", "zh-hant", "ja", "ko"] { let l = lang(code); let ctx = egui::Context::default(); ctx.set_fonts(crate::theme::font_definitions_language(l.prefers_hans(), l.prefers_korean(), false)); ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); - let (entries, errors) = parse_entries(l.0.source); - assert!(errors.is_empty(), "{code}: {errors:?}"); + let entries = translations(code); let chars: std::collections::BTreeSet = - entries.iter().flat_map(|e| e.translation.chars()).chain(names.chars()).filter(|c| wordcraft_fonts::is_cjk(*c)).collect(); + entries.iter().flat_map(|e| e.chars()).chain(names.chars()).filter(|c| wordcraft_fonts::is_cjk(*c)).collect(); assert!(chars.len() > 300, "{code}: {} CJK characters", chars.len()); for font in [crate::theme::regular(14.0), crate::theme::medium(14.0), crate::theme::semibold(14.0)] { let missing: String = ctx.fonts_mut(|f| chars.iter().filter(|c| !f.has_glyph(&font, **c)).collect()); assert!(missing.is_empty(), "{code}: no glyph in {font:?} for {missing}"); } // So the interface reads no installed CJK font for these languages. + let mut texts: Vec<&str> = l.catalog_texts().collect(); + texts.push(&names); assert_eq!( - wordcraft_fonts::ui_uncovered_cjk(&[l.0.source, &names], &wordcraft_fonts::ui_cjk_fonts_for(l.prefers_hans(), l.prefers_korean())), + wordcraft_fonts::ui_uncovered_cjk(&texts, &wordcraft_fonts::ui_cjk_fonts_for(l.prefers_hans(), l.prefers_korean())), Vec::::new() ); } @@ -397,6 +397,7 @@ fn cjk_catalogs_are_covered_by_the_embedded_interface_faces() { #[test] fn only_cjk_languages_ask_for_an_installed_cjk_font() { + needs_catalogs!(); // #241 follow-up: an English interface must not read a large system CJK font on startup. let cjk: Vec<&str> = Lang::all().filter(|l| l.uses_cjk()).map(Lang::code).collect(); assert_eq!(cjk, ["zh-hans", "zh-hant", "ja", "ko"]); @@ -404,6 +405,7 @@ fn only_cjk_languages_ask_for_an_installed_cjk_font() { #[test] fn russian_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); let ru = lang("ru"); 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}"); @@ -424,11 +426,11 @@ fn russian_locales_and_saved_preference_work_without_changing_the_document() { #[test] fn bundled_interface_fonts_cover_russian_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); - let (entries, errors) = parse_entries(lang("ru").0.source); - assert!(errors.is_empty()); + needs_catalogs!(); + let entries = translations("ru"); let chars: std::collections::HashSet = entries .iter() - .flat_map(|e| e.translation.chars()) + .flat_map(|e| e.chars()) // Cyrillic letters plus the Russian quotes and dash; symbols such as ¶, ⌘ or 📂 come from // the English sources and render as they already do. .filter(|c| ('\u{0400}'..='\u{04FF}').contains(c) || matches!(c, '«' | '»' | '—')) @@ -471,15 +473,7 @@ fn built_in_style_names_are_translated_for_display_and_user_styles_are_not() { } #[test] -fn context_entries_override_only_their_context_and_unknown_contexts_are_rejected() { - let (c, errors) = Catalog::parse("\tTitle\tЗаголовок\npreview\tTitle\tТитул\n\tFont\tШрифт\nnope\tFont\tX\n"); - assert_eq!(errors.len(), 1, "{errors:?}"); - assert!(errors[0].contains("unknown context")); - assert_eq!(c.plain("Title"), Some("Заголовок")); - assert_eq!(c.in_context("preview", "Title"), Some("Титул")); - // Without a context entry, a context lookup uses the plain one. - assert_eq!(c.in_context("preview", "Font"), Some("Шрифт")); - assert_eq!(c.in_context("preview", "Missing"), None); +fn context_entries_override_only_their_context() { let fx = fixture_lang("x-fixture", "\tTitle\tTitle-FX\npreview\tTitle\tSample-FX\n"); assert_eq!(tr(fx, "Title"), "Title-FX"); assert_eq!(trc(fx, "preview", "Title"), "Sample-FX"); @@ -490,6 +484,7 @@ fn context_entries_override_only_their_context_and_unknown_contexts_are_rejected #[test] fn estonian_locales_and_saved_preference_keep_document_content() { + needs_catalogs!(); let et = lang("et"); 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}"); @@ -522,6 +517,7 @@ fn bundled_interface_fonts_cover_estonian_letters() { #[test] fn turkish_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); let tr = lang("tr"); 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}"); @@ -548,11 +544,10 @@ fn turkish_locales_and_saved_preference_work_without_changing_the_document() { #[test] fn bundled_interface_fonts_cover_turkish_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); - let (entries, errors) = parse_entries(lang("tr").0.source); - assert!(errors.is_empty()); + needs_catalogs!(); + let entries = translations("tr"); let extra = "ÇçĞğİıÖöŞşÜü"; - let chars: std::collections::HashSet = - entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(*c)).chain(extra.chars()).collect(); + let chars: std::collections::HashSet = entries.iter().flat_map(|e| e.chars()).filter(|c| extra.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"); @@ -565,6 +560,7 @@ fn bundled_interface_fonts_cover_turkish_without_system_fallbacks() { #[test] fn hungarian_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); let hu = lang("hu"); for tag in ["hu", "hu-HU", "hu_HU.UTF-8", "HU_hu.UTF-8@euro", "hu-Latn-HU"] { assert_eq!(lang_from_tag(tag), Some(hu), "{tag}"); @@ -587,11 +583,10 @@ fn hungarian_locales_and_saved_preference_work_without_changing_the_document() { #[test] fn bundled_interface_fonts_cover_hungarian_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); - let (entries, errors) = parse_entries(lang("hu").0.source); - assert!(errors.is_empty()); + needs_catalogs!(); + let entries = translations("hu"); let extra = "ÁáÉéÍíÓóÖöŐőÚúÜüŰű"; - let chars: std::collections::HashSet = - entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(*c)).chain(extra.chars()).collect(); + let chars: std::collections::HashSet = entries.iter().flat_map(|e| e.chars()).filter(|c| extra.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"); @@ -604,6 +599,7 @@ fn bundled_interface_fonts_cover_hungarian_without_system_fallbacks() { #[test] fn french_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); 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}"); @@ -630,12 +626,12 @@ fn french_locales_and_saved_preference_work_without_changing_the_document() { #[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()); + needs_catalogs!(); + let entries = translations("fr"); // French letters and punctuation: accents, ligatures, guillemets and the typographic apostrophe. let extra = "ÀàÂâÆæÇçÉéÈèÊêËëÎîÏïÔôŒœÙùÛûÜüŸÿ«»’"; let chars: std::collections::HashSet = - entries.iter().flat_map(|e| e.translation.chars()).filter(|c| ('\u{00C0}'..='\u{017F}').contains(c)).chain(extra.chars()).collect(); + entries.iter().flat_map(|e| e.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"); @@ -656,6 +652,7 @@ fn bundled_interface_fonts_cover_french_without_system_fallbacks() { #[test] fn polish_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); 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}"); @@ -676,12 +673,12 @@ fn polish_locales_and_saved_preference_work_without_changing_the_document() { #[test] fn bundled_interface_fonts_cover_polish_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); - let (entries, errors) = parse_entries(lang("pl").0.source); - assert!(errors.is_empty(), "{errors:?}"); + needs_catalogs!(); + let entries = translations("pl"); // Polish letters beyond ASCII, both cases, and the Polish quotation marks. let polish = "ĄąĆćĘꣳŃńÓóŚśŹźŻż„”"; let chars: std::collections::HashSet = - entries.iter().flat_map(|e| e.translation.chars()).filter(|c| polish.contains(*c)).chain(polish.chars()).collect(); + entries.iter().flat_map(|e| e.chars()).filter(|c| polish.contains(*c)).chain(polish.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"); @@ -693,6 +690,7 @@ fn bundled_interface_fonts_cover_polish_without_system_fallbacks() { #[test] fn a_polish_interface_writes_new_documents_in_polish() { + needs_catalogs!(); let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); let ctx = egui::Context::default(); let frame = |app: &mut crate::WordApp| ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())).drop_without_applying_deltas(); @@ -724,6 +722,7 @@ fn a_polish_interface_writes_new_documents_in_polish() { #[test] fn a_czech_interface_writes_new_documents_and_plain_text_in_czech() { + needs_catalogs!(); let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); let ctx = egui::Context::default(); let frame = |app: &mut crate::WordApp| ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())).drop_without_applying_deltas(); @@ -753,6 +752,7 @@ fn a_czech_interface_writes_new_documents_and_plain_text_in_czech() { #[test] fn an_italian_interface_writes_new_documents_in_italian() { + needs_catalogs!(); let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); let ctx = egui::Context::default(); let frame = |app: &mut crate::WordApp| ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())).drop_without_applying_deltas(); @@ -775,12 +775,13 @@ fn an_italian_interface_writes_new_documents_in_italian() { #[test] fn the_editing_languages_are_the_ones_wordcraft_can_proof() { - let with: Vec<(&str, &str)> = Lang::all().filter_map(|l| l.editing_lang().map(|e| (l.code(), e))).collect(); + let with: Vec<(&str, &str)> = LANGUAGES.iter().filter_map(|l| l.editing_lang.map(|e| (l.code, e))).collect(); assert_eq!(with, [("pt-br", "pt-BR"), ("pl", "pl-PL"), ("cs", "cs-CZ"), ("tr", "tr-TR"), ("it", "it-IT")]); } #[test] fn german_locales_and_saved_preference_keep_document_content() { + needs_catalogs!(); let de = lang("de"); 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}"); @@ -813,6 +814,7 @@ fn bundled_interface_fonts_cover_german_letters() { #[test] fn italian_locales_and_saved_preference_keep_document_content() { + needs_catalogs!(); let it = lang("it"); 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}"); @@ -845,6 +847,7 @@ fn bundled_interface_fonts_cover_italian_letters() { #[test] fn bulgarian_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); let bg = lang("bg"); for tag in ["bg", "bg-BG", "bg_BG.UTF-8", "BG-bg", "bg-Cyrl-BG"] { assert_eq!(lang_from_tag(tag), Some(bg), "{tag}"); @@ -866,11 +869,11 @@ fn bulgarian_locales_and_saved_preference_work_without_changing_the_document() { #[test] fn bundled_interface_fonts_cover_bulgarian_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); - let (entries, errors) = parse_entries(lang("bg").0.source); - assert!(errors.is_empty()); + needs_catalogs!(); + let entries = translations("bg"); let chars: std::collections::HashSet = entries .iter() - .flat_map(|e| e.translation.chars()) + .flat_map(|e| e.chars()) // Cyrillic letters plus the Bulgarian quotes and dash; symbols come from the English sources. .filter(|c| ('\u{0400}'..='\u{04FF}').contains(c) || matches!(c, '„' | '“' | '—' | '–')) .chain("АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЬЮЯабвгдежзийклмнопрстуфхцчшщъьюя".chars()) @@ -887,6 +890,7 @@ fn bundled_interface_fonts_cover_bulgarian_without_system_fallbacks() { #[test] fn croatian_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); let hr = lang("hr"); for tag in ["hr", "hr-HR", "hr_HR.UTF-8", "HR-hr", "hr-Latn-HR", "hr-BA"] { assert_eq!(lang_from_tag(tag), Some(hr), "{tag}"); @@ -908,11 +912,11 @@ fn croatian_locales_and_saved_preference_work_without_changing_the_document() { #[test] fn bundled_interface_fonts_cover_croatian_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); - let (entries, errors) = parse_entries(lang("hr").0.source); - assert!(errors.is_empty()); + needs_catalogs!(); + let entries = translations("hr"); let chars: std::collections::HashSet = entries .iter() - .flat_map(|e| e.translation.chars()) + .flat_map(|e| e.chars()) // Croatian letters plus its quotes and dashes; symbols come from the English sources. .filter(|c| matches!(c, '„' | '”' | '–')) .chain("ČčĆćĐ𩹮ž".chars()) @@ -929,6 +933,7 @@ fn bundled_interface_fonts_cover_croatian_without_system_fallbacks() { #[test] fn swedish_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); let sv = lang("sv"); for tag in ["sv", "sv-SE", "sv_SE.UTF-8", "SV-se", "sv-FI"] { assert_eq!(lang_from_tag(tag), Some(sv), "{tag}"); @@ -950,11 +955,11 @@ fn swedish_locales_and_saved_preference_work_without_changing_the_document() { #[test] fn bundled_interface_fonts_cover_swedish_letters() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); - let (entries, errors) = parse_entries(lang("sv").0.source); - assert!(errors.is_empty()); + needs_catalogs!(); + let entries = translations("sv"); let chars: std::collections::HashSet = entries .iter() - .flat_map(|e| e.translation.chars()) + .flat_map(|e| e.chars()) // Latin letters with diacritics plus the Swedish quote and dash; symbols come from the English sources. .filter(|c| ('\u{00C0}'..='\u{00FF}').contains(c) || matches!(c, '”' | '–')) .chain("ÅÄÖåäöÉé".chars()) @@ -971,6 +976,7 @@ fn bundled_interface_fonts_cover_swedish_letters() { #[test] fn vietnamese_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); let vi = lang("vi"); for tag in ["vi", "vi-VN", "vi_VN.UTF-8", "VI-vi", "vi-Latn-VN", "vi-US"] { assert_eq!(lang_from_tag(tag), Some(vi), "{tag}"); @@ -993,15 +999,15 @@ fn vietnamese_locales_and_saved_preference_work_without_changing_the_document() #[test] fn bundled_interface_fonts_cover_vietnamese_letters() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); - let (entries, errors) = parse_entries(lang("vi").0.source); - assert!(errors.is_empty(), "{errors:?}"); + needs_catalogs!(); + let entries = translations("vi"); // Cover the catalog's Latin letters and combining marks, and all 134 non-ASCII Vietnamese // letters even when a letter is not used by the current translations. U+1EA0..U+1EF9 contains // the Vietnamese vowel/tone combinations; the other forms are in earlier Latin blocks. let extra = "ÀàÁáÃãÈèÉéÌìÍíÒòÓóÕõÙùÚúÝýÂâĂăĐđÊêÔôƠơƯưĨĩŨũ"; let chars: std::collections::HashSet = entries .iter() - .flat_map(|e| e.translation.chars()) + .flat_map(|e| e.chars()) .filter(|c| ('\u{00C0}'..='\u{024F}').contains(c) || ('\u{0300}'..='\u{036F}').contains(c) || ('\u{1E00}'..='\u{1EFF}').contains(c)) .chain(extra.chars()) .chain('\u{1EA0}'..='\u{1EF9}') @@ -1020,17 +1026,18 @@ fn bundled_interface_fonts_cover_vietnamese_letters() { #[test] fn spooled_print_receipt_is_translated() { use crate::printing::{Feedback, feedback}; - let (c, errors) = Catalog::parse( + let fx = fixture_lang( + "x-fixture", "\tPrint job submitted: spooled {count} sheet(s)\tSpooled-FX: {count}\n\tPrint job submitted: {receipt}\tSubmitted-FX: {receipt}\n", ); - assert!(errors.is_empty(), "{errors:?}"); let receipt = || Feedback::Submitted("spooled 2 sheet(s)".into()); - assert_eq!(feedback(&receipt(), |s| c.plain(s).unwrap_or(s).to_owned()), "Spooled-FX: 2"); + assert_eq!(feedback(&receipt(), |s| tr(fx, s).to_owned()), "Spooled-FX: 2"); assert_eq!(feedback(&receipt(), str::to_owned), "Print job submitted: spooled 2 sheet(s)"); } #[test] fn romanian_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); let ro = lang("ro"); for tag in ["ro", "ro-RO", "ro_RO.UTF-8", "RO-ro", "ro-Latn-RO", "ro-MD"] { assert_eq!(lang_from_tag(tag), Some(ro), "{tag}"); @@ -1053,12 +1060,11 @@ fn romanian_locales_and_saved_preference_work_without_changing_the_document() { #[test] fn bundled_interface_fonts_cover_romanian_without_system_fallbacks() { let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); - let (entries, errors) = parse_entries(lang("ro").0.source); - assert!(errors.is_empty()); + needs_catalogs!(); + let entries = translations("ro"); // Romanian's letters beyond plain ASCII (comma-below ș ț) and its quotation marks. let extra = ['ă', 'â', 'î', 'ș', 'ț', 'Ă', 'Â', 'Î', 'Ș', 'Ț', '„', '”']; - let chars: std::collections::HashSet = - entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(c)).chain(extra).collect(); + let chars: std::collections::HashSet = entries.iter().flat_map(|e| e.chars()).filter(|c| extra.contains(c)).chain(extra).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"); @@ -1070,6 +1076,7 @@ fn bundled_interface_fonts_cover_romanian_without_system_fallbacks() { #[test] fn slovenian_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); let sl = lang("sl"); for tag in ["sl", "sl-SI", "sl_SI.UTF-8", "SL-si", "sl-Latn-SI"] { assert_eq!(lang_from_tag(tag), Some(sl), "{tag}"); @@ -1089,6 +1096,7 @@ fn slovenian_locales_and_saved_preference_work_without_changing_the_document() { #[test] fn bundled_interface_fonts_cover_slovenian_letters() { + needs_catalogs!(); let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] { let face = db.face(family, style); @@ -1099,3 +1107,50 @@ fn bundled_interface_fonts_cover_slovenian_letters() { } assert!(!lang("sl").uses_cjk()); } + +/// The "Open" paragraph-spacing preset is roomy spacing, not the file verb: only a `spacing` row +/// translates it. +#[test] +fn a_context_lookup_never_borrows_the_plain_translation() { + set_current(fixture_lang("x-fixture", "\tOpen\tOpen-file-FX\n")); + assert_eq!(t("Open"), "Open-file-FX"); + assert_eq!(get("spacing", "Open"), None); + set_current(fixture_lang("x-fixture", "\tOpen\tOpen-file-FX\nspacing\tOpen\tRoomy-FX\n")); + assert_eq!(get("spacing", "Open"), Some("Roomy-FX")); + set_current(Lang::EN); + assert_eq!(get("spacing", "Open"), None); +} + +/// WordCraft offers exactly the languages in `LANGUAGES` when craft-i18n is embedded, and English +/// alone when it isn't: a missing catalog never fails, it shows English. +#[test] +fn the_languages_offered_are_the_embedded_ones_of_the_list() { + let all: Vec<&str> = Lang::all().map(Lang::code).collect(); + if CRAFT_I18N.is_empty() { + assert_eq!(all, ["en"]); + assert_eq!(Lang::from_code("fr"), None); + assert_eq!(Lang::from_pref("fr"), Lang::EN, "a saved language without a catalog shows English"); + assert_eq!(normalize_pref("fr"), None); + assert_eq!(tr(Lang::EN, "File"), "File"); + return; + } + let listed: Vec<&str> = LANGUAGES.iter().map(|l| l.code).collect(); + assert_eq!(all, listed, "every language WordCraft lists has its catalog in craft-i18n"); + assert!(Lang::from_code("pseudo").is_none(), "the pseudo locale is never a setting"); +} + +/// The flatpak manifest builds with the craft-i18n commit `craft-i18n.rev` pins (CI and releases +/// read the file; the manifest has to name it itself). +#[test] +fn the_flatpak_manifest_pins_the_craft_i18n_rev() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let rev = std::fs::read_to_string(root.join("craft-i18n.rev")).unwrap(); + let rev = rev.trim(); + assert!(rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit()), "craft-i18n.rev holds one full SHA: {rev:?}"); + let manifest = std::fs::read_to_string(root.join("packaging/linux/flatpak/ai.storyteller.wordcraft.yml")).unwrap(); + let source = manifest.split("url: https://github.com/storytold/craft-i18n").nth(1).expect("a craft-i18n git source"); + let source = source.split("- type:").next().unwrap_or(source); + let commit = source.lines().find_map(|l| l.trim().strip_prefix("commit:")).expect("the craft-i18n source pins a commit"); + assert_eq!(commit.trim().trim_matches(['\'', '"']), rev); + assert!(manifest.contains("CRAFT_I18N_DIR: /run/build/wordcraft/craft-i18n"), "the build embeds the catalogs"); +} diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index a1f7f727b..ad10b9cf0 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -878,6 +878,9 @@ mod tests { /// label. AccessKit must still expose the full name and the row must keep its fixed size. #[test] fn options_nav_labels_stay_inside_their_rows() { + if !crate::i18n::catalogs_embedded() { + return; + } for language in ["en", "ru"] { for percent in [100, 125, 150] { let mut h = nav_harness(language, percent); @@ -921,6 +924,9 @@ mod tests { /// backgrounds intact, and the selected pane's settings visible at every interface size. #[test] fn options_nav_rows_keep_hover_and_selection() { + if !crate::i18n::catalogs_embedded() { + return; + } for language in ["en", "ru"] { for percent in [100, 125, 150] { let mut h = nav_harness(language, percent); @@ -1210,6 +1216,9 @@ mod tests { h.get_by_label("Animate the caret (it glides and fades)").click(); h.run_steps(4); assert!(h.state().ui.caret_animation, "and on again"); + if !crate::i18n::catalogs_embedded() { + return; + } h.state_mut().run("ui.language", json!({"value": "es"})).unwrap(); h.run_steps(4); assert!(h.query_by_label("Animación").is_some() && h.query_by_label("Animation").is_none()); @@ -1252,6 +1261,9 @@ mod tests { /// takes the unit typed either way. #[test] fn length_units_follow_the_interface_language() { + if !crate::i18n::catalogs_embedded() { + return; + } let mut a = app(); a.run("ui.language", json!({"value": "ru"})).unwrap(); a.run("file.options", json!({"region": "ru-RU"})).unwrap(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index fc43cfbcd..ce9f6870f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1647,6 +1647,12 @@ fn design(app: &mut WordApp, ui: &mut Ui) { ("Relaxed", "relaxed"), ("Double", "double"), ] { + // "Open" here is roomy spacing, not the file verb: only a `spacing` row + // translates it, else it stays English. + if l == "Open" { + mi_text(ui, app, crate::i18n::get("spacing", l).unwrap_or(l), "design.paragraphSpacing", json!({"value": v})); + continue; + } mi(ui, app, l, "design.paragraphSpacing", json!({"value": v})); } }); @@ -3434,6 +3440,9 @@ mod tests { /// in Polish the table tabs were out of reach. They stay on the strip and a click opens them. #[test] fn table_tabs_stay_clickable_when_the_tab_strip_overflows() { + if !crate::i18n::catalogs_embedded() { + return; + } use egui_kittest::kittest::Queryable; let mut app = WordApp::new(Session::new(wordcraft_doc::Document::from_text("Text")), crate::Services::default()); app.run("insert.table", json!({"rows": 3, "cols": 3})).unwrap(); @@ -3461,6 +3470,9 @@ mod tests { /// Editing / Share, the tabs stay inside the window, and the buttons keep their room. #[test] fn tab_strip_never_overlaps_the_right_buttons() { + if !crate::i18n::catalogs_embedded() { + return; + } use egui_kittest::kittest::Queryable; for lang in ["en", "pl", "de"] { for width in [300.0, 420.0, 520.0, 640.0, 765.0, 1280.0] {