mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 02:17:24 -06:00
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e649cd09da
commit
2d90f2d071
@@ -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<String>) {
|
||||
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<String>) {
|
||||
// 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::<String>());
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+159
-211
@@ -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/<locale>/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/<locale>/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/<code>/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<I18n> = 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<Catalog>,
|
||||
}
|
||||
|
||||
/// 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<Item = &'static str> {
|
||||
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::<Vec<_>>().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::<Vec<_>>().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<Lang> {
|
||||
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<Item = Lang> {
|
||||
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<String> {
|
||||
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<Lang> {
|
||||
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)]
|
||||
|
||||
@@ -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<bool> = const { Cell::new(false) };
|
||||
// Marked strings live for the test process; lookups hand out `&'static str`.
|
||||
static CACHE: RefCell<HashMap<String, &'static str>> = RefCell::new(HashMap::new());
|
||||
// Every English string looked up while on, to check against a real catalog.
|
||||
static LOOKED_UP: RefCell<BTreeSet<String>> = 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<String> {
|
||||
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 {
|
||||
|
||||
+163
-108
@@ -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<String> {
|
||||
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<char> =
|
||||
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<char> =
|
||||
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<char> = 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<char> =
|
||||
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(c)).chain(extra).collect();
|
||||
let chars: std::collections::HashSet<char> = 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<char> = 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<char> = 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<char> =
|
||||
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::<char>::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<char> = 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<char> =
|
||||
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(*c)).chain(extra.chars()).collect();
|
||||
let chars: std::collections::HashSet<char> = 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<char> =
|
||||
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(*c)).chain(extra.chars()).collect();
|
||||
let chars: std::collections::HashSet<char> = 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<char> =
|
||||
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<char> =
|
||||
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<char> = 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<char> = 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<char> = 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<char> = 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<char> =
|
||||
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(c)).chain(extra).collect();
|
||||
let chars: std::collections::HashSet<char> = 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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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] {
|
||||
|
||||
Reference in New Issue
Block a user