Merge pull request #4072 from benandez/craft-i18n-pilot

i18n: interface translations from craft-i18n (pilot)
This commit is contained in:
skylar taylor-barrick
2026-10-23 17:00:38 -06:00
committed by GitHub
46 changed files with 536 additions and 69871 deletions
+1
View File
@@ -0,0 +1 @@
craft-i18n.rev text eol=lf
+1 -15
View File
@@ -67,18 +67,4 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled |
| `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog |
| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent); Aleksander Kutycki (@olekkut) with Gemini 3.8 Flash | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker |
| `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled |
| `crates/ui-egui/src/i18n/cs.tsv` | @DanielKnourek with Antigravity for VS Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Czech interface catalog; review suggestions by @Losminek |
| `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog |
| `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker |
| `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog |
| `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog |
| `crates/ui-egui/src/i18n/sl.tsv` | Filip Strajnar with Grok Bot | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Slovenian interface catalog |
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog |
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog |
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Swedish interface catalog; AI-assisted, terminology and the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/hr.tsv` | Andrej Škvorc (@andrejskvorc) with Claude Code (Sonnet 5.5); proofread with Gemini | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Croatian interface catalog; reviewed and corrected in the running interface, menu by menu, by the native-speaker author, who also checked the Gemini suggestions |
| `crates/ui-egui/src/i18n/ro.tsv` | Viorel Toader (@vioreltus) with Claude Code (Opus 5.5) | Original translation from the English interface labels in this repository; no other product localisation copied (a few dozen short, generic labels were reused from the PdfCraft Romanian catalog written the same way) | MIT OR Apache-2.0 | Romanian interface catalog; AI-assisted draft, not yet reviewed line by line by a native speaker |
| craft-i18n (optional build input, not files in this repo): the interface translations | see https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md, which credits every translator of WordCraft's former in-repo catalogs (`crates/ui-egui/src/i18n/*.tsv`, migrated at `wordcraft@806a945`) | embedded only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | MIT OR Apache-2.0 | Interface languages other than English; clean-room translations from the English interface labels |
Generated
+41
View File
@@ -869,6 +869,17 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f7cc8a7df15e824b02cf73859567a6242d3a7de4#f7cc8a7df15e824b02cf73859567a6242d3a7de4"
dependencies = [
"intl_pluralrules",
"log",
"sys-locale",
"unic-langid",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
@@ -2080,6 +2091,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "intl_pluralrules"
version = "7.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972"
dependencies = [
"unic-langid",
]
[[package]]
name = "is-docker"
version = "0.2.0"
@@ -4014,6 +4034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"serde_core",
"zerovec",
]
@@ -4161,6 +4182,24 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unic-langid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
dependencies = [
"unic-langid-impl",
]
[[package]]
name = "unic-langid-impl"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
dependencies = [
"tinystr",
]
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -5400,6 +5439,7 @@ name = "wordcraft-ui-egui"
version = "0.10.0"
dependencies = [
"cfb",
"craft-i18n",
"craft-ui",
"egui",
"egui_kittest",
@@ -5754,6 +5794,7 @@ version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"serde",
"yoke",
"zerofrom",
"zerovec-derive",
+3 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
04600cbb1508f1b15927ec574a5e32e5f376fd8b
+4
View File
@@ -10,6 +10,10 @@ workspace = true
[dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
# Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n
# catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in
# `craft-i18n.rev`). Its own rev: lazy per-locale parsing (storytold/craft-libs#247).
craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "f7cc8a7df15e824b02cf73859567a6242d3a7de4" }
wordcraft-geom = { workspace = true }
wordcraft-doc = { workspace = true }
unicode-bidi = { workspace = true }
+81 -1
View File
@@ -1,5 +1,6 @@
//! Bake the contributor credits into the binary as static Rust tables (craftrules
//! standards/contributors.md). Nothing is read at run time.
//! standards/contributors.md), and embed the craft-i18n interface catalogs. Nothing is read at
//! run time.
//!
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
@@ -7,6 +8,12 @@
//! JSON says the entry was verified (`people_entry`).
//!
//! A missing or malformed file gives empty tables (or no names) and a warning.
//!
//! - `CRAFT_I18N_DIR`, a craft-i18n checkout (storytold/craft-i18n, at the SHA in
//! `craft-i18n.rev`): `craft_i18n.rs` holds `CRAFT_I18N`, one (locale, shared catalog, WordCraft
//! catalog) entry per locale that has a `wordcraft.tsv`. Unset, or not a checkout (a warning),
//! it is empty and the interface is English only; with `CRAFT_I18N_REQUIRED=1` (releases) that
//! is an error instead. Recipe: craft-i18n's `docs/integration.md`.
use std::fmt::Write as _;
@@ -24,6 +31,79 @@ fn main() {
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
println!("cargo::warning=writing {}: {e}", out.display());
}
let out = out.with_file_name("craft_i18n.rs");
if let Err(e) = std::fs::write(&out, craft_i18n()) {
println!("cargo::error=writing {}: {e}", out.display());
}
}
/// The catalog this app reads in each locale: `locales/<locale>/wordcraft.tsv`.
const APP_CATALOG: &str = "wordcraft.tsv";
/// `CRAFT_I18N` from `CRAFT_I18N_DIR`: the `Embedded` list the craft-i18n crate reads.
fn craft_i18n() -> String {
println!("cargo::rerun-if-env-changed=CRAFT_I18N_DIR");
println!("cargo::rerun-if-env-changed=CRAFT_I18N_REQUIRED");
let required = std::env::var("CRAFT_I18N_REQUIRED").is_ok_and(|v| v.trim() == "1");
let entries = match std::env::var_os("CRAFT_I18N_DIR").filter(|d| !d.is_empty()) {
// Not canonicalized: on Windows that gives `\\?\C:\…`, which `include_str!` can't read.
Some(dir) => match locales(&std::path::PathBuf::from(dir).join("locales")) {
Ok(entries) => entries,
Err(e) => {
let level = if required { "error" } else { "warning" };
println!("cargo::{level}=CRAFT_I18N_DIR: {e}; the interface is English only");
Vec::new()
}
},
None => {
if required {
println!("cargo::error=CRAFT_I18N_REQUIRED=1 but CRAFT_I18N_DIR is unset: a release must embed the translations");
}
Vec::new()
}
};
let mut o = String::from("/// (locale, shared catalog, WordCraft's catalog), one entry per locale; `\"\"` for a missing file.\n");
o.push_str("pub static CRAFT_I18N: &craft_i18n::Embedded = &[\n");
for (code, shared, app) in entries {
// `{:?}` escapes the path (Windows backslashes) as a Rust string literal.
let shared = shared.map_or_else(|| "\"\"".to_string(), |p| format!("include_str!({p:?})"));
let _ = writeln!(o, " ({code:?}, {shared}, include_str!({app:?})),");
}
o.push_str("];\n");
o
}
/// Every locale under `dir` with this app's catalog, by code: (code, its `common.tsv` if any, its
/// app catalog). An error when `dir` isn't a craft-i18n `locales/` directory.
#[allow(clippy::type_complexity)]
fn locales(dir: &std::path::Path) -> Result<Vec<(String, Option<std::path::PathBuf>, std::path::PathBuf)>, String> {
// Build scripts run in the crate's directory and `include_str!` resolves against `OUT_DIR`, so
// a relative path would mean two different places.
if !dir.is_absolute() {
return Err(format!("{} is not an absolute path", dir.display()));
}
let list = std::fs::read_dir(dir).map_err(|e| format!("{} is not a craft-i18n checkout ({e})", dir.display()))?;
println!("cargo::rerun-if-changed={}", dir.display());
let mut out = Vec::new();
for entry in list.flatten() {
let path = entry.path();
let Some(code) = path.file_name().and_then(|n| n.to_str()).map(str::to_string) else { continue };
let app = path.join(APP_CATALOG);
if !app.is_file() {
continue;
}
println!("cargo::rerun-if-changed={}", app.display());
let shared = Some(path.join("common.tsv")).filter(|p| p.is_file());
if let Some(p) = &shared {
println!("cargo::rerun-if-changed={}", p.display());
}
out.push((code, shared, app));
}
if out.is_empty() {
return Err(format!("{} has no locale with a {APP_CATALOG}", dir.display()));
}
out.sort();
Ok(out)
}
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
+20 -6
View File
@@ -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());
}
}
}
File diff suppressed because it is too large Load Diff
-142
View File
@@ -1,142 +0,0 @@
//! Parser and lookup table for translation catalogs (`*.tsv`, format documented in `zh-hans.tsv`).
//! The format is PdfCraft's. Most entries are plain (empty context); a few use one of
//! [`CONTEXTS`] where the same English needs different wording in one place.
//!
//! Validation is strict (unknown escapes, unknown `@` contexts, placeholder, ellipsis and plural
//! form mismatches, duplicates), but a bad line is only skipped and reported: the rest of the
//! catalog still loads and the skipped string shows in English. The tests insist the bundled
//! catalogs have no errors at all.
use std::collections::{HashMap, HashSet};
/// A catalog entry as read from the file.
#[derive(Debug, PartialEq, Eq)]
pub struct Entry {
pub context: String,
pub source: String,
pub translation: String,
}
/// The contexts a catalog entry may name, and where each applies.
/// - `preview`: sample text inside a rendered preview (the Design tab's style-set tiles), which
/// has to fit a small tile; it falls back to the plain entry.
/// - `template`: the name of a built-in document template (Home and New), which may need other
/// wording than the same English word elsewhere (the "Letter" template and the "Letter" paper
/// size); it falls back to the plain entry.
pub const CONTEXTS: &[&str] = &["preview", "template"];
/// A parsed catalog. Values are owned for the life of the process (catalogs are built once).
#[derive(Debug, Default)]
pub struct Catalog {
/// English source → translation (the hot path, looked up every frame).
plain: HashMap<String, String>,
/// (context, English source) → translation, for the few entries that need one.
contextual: HashMap<(String, String), String>,
}
fn unescape(text: &str) -> Result<String, String> {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('\\') => out.push('\\'),
Some(other) => return Err(format!("unknown escape \\{other}; use \\n, \\t or \\\\")),
None => return Err("trailing backslash; use \\\\ for a literal backslash".into()),
}
}
Ok(out)
}
/// `{name}` placeholders of a template, sorted (translators may reorder them).
pub fn placeholders(text: &str) -> Vec<&str> {
let mut names = Vec::new();
let mut rest = text;
while let Some((_, after_open)) = rest.split_once('{') {
let Some((name, after_close)) = after_open.split_once('}') else { break };
names.push(name);
rest = after_close;
}
names.sort_unstable();
names
}
/// Check one entry against its English source.
fn validate(entry: &Entry) -> Result<(), String> {
let Entry { context, source, translation } = entry;
if !context.is_empty() && !CONTEXTS.contains(&context.as_str()) {
return Err(format!("unknown context {context:?}; use one of {CONTEXTS:?} or leave the first column empty"));
}
if placeholders(source) != placeholders(translation) {
return Err("placeholders differ from the English source".into());
}
if source.ends_with('…') != translation.ends_with('…') {
return Err("trailing ellipsis differs from the English source".into());
}
Ok(())
}
fn parse_line(line: &str) -> Result<Entry, String> {
let mut columns = line.split('\t');
let (Some(context), Some(source), Some(translation), None) = (columns.next(), columns.next(), columns.next(), columns.next()) else {
return Err("expected `context<TAB>source<TAB>translation`".into());
};
let entry = Entry { context: unescape(context)?, source: unescape(source)?, translation: unescape(translation)? };
if entry.source.is_empty() || entry.translation.is_empty() {
return Err("source and translation must be nonempty".into());
}
validate(&entry)?;
Ok(entry)
}
/// Read the entries of a catalog file. Malformed lines and duplicates (the first entry wins) are
/// returned as errors and skipped, so a bad translation never breaks the UI.
pub fn parse_entries(text: &str) -> (Vec<Entry>, Vec<String>) {
let mut entries = Vec::new();
let mut errors = Vec::new();
let mut seen = HashSet::new();
for (index, line) in text.lines().enumerate() {
if line.trim().is_empty() || line.starts_with('#') {
continue;
}
let line_no = index.saturating_add(1);
match parse_line(line) {
Ok(entry) if !seen.insert((entry.context.clone(), entry.source.clone())) => {
errors.push(format!("line {line_no}: duplicate key {:?} / {:?}", entry.context, entry.source));
}
Ok(entry) => entries.push(entry),
Err(error) => errors.push(format!("line {line_no}: {error}")),
}
}
(entries, errors)
}
impl Catalog {
/// Build a catalog from its valid entries; the errors of the skipped lines come back too.
pub fn parse(text: &str) -> (Catalog, Vec<String>) {
let (entries, errors) = parse_entries(text);
let mut catalog = Catalog::default();
for e in entries {
if e.context.is_empty() {
catalog.plain.insert(e.source, e.translation);
} else {
catalog.contextual.insert((e.context, e.source), e.translation);
}
}
(catalog, errors)
}
pub fn plain(&self, s: &str) -> Option<&str> {
self.plain.get(s).map(String::as_str)
}
/// The entry for `s` in `context`, else the plain one.
pub fn in_context(&self, context: &str, s: &str) -> Option<&str> {
self.contextual.get(&(context.to_string(), s.to_string())).map(String::as_str).or_else(|| self.plain(s))
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+159 -211
View File
@@ -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)]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -15
View File
@@ -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 {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+163 -108
View File
@@ -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");
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12
View File
@@ -886,6 +886,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);
@@ -929,6 +932,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);
@@ -1218,6 +1224,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());
@@ -1260,6 +1269,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();
+12
View File
@@ -1663,6 +1663,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}));
}
});
@@ -3450,6 +3456,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();
@@ -3477,6 +3486,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] {
+3 -2
View File
@@ -1,6 +1,6 @@
# Hungarian interface
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: the interface audit lists missing Hungarian entries instead of failing; previously minor: Hungarian interface catalog and regression checks) · **Target:** WordCraft 0.5.0
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (the catalog moved to storytold/craft-i18n, `locales/hu/wordcraft.tsv`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: the interface audit lists missing Hungarian entries instead of failing; previously minor: Hungarian interface catalog and regression checks) · **Target:** WordCraft 0.5.0
Choose **File › Options › Interface language › Magyar**, or follow a Hungarian system display
language (`hu-HU`, `hu_HU.UTF-8`). The catalog code is `hu`; `ui.language` with `{"value":"hu"}`
@@ -8,7 +8,7 @@ selects it explicitly, and `WORDCRAFT_LANGUAGE=hu-HU` does the same for a headle
launch while the preference is `auto`. The choice is saved with the other interface settings.
Document text, command ids, CLI/MCP/control responses and file paths do not change.
`crates/ui-egui/src/i18n/hu.tsv` has 2,419 entries: every key of the Russian catalog (built-in style
craft-i18n's `locales/hu/wordcraft.tsv` (storytold/craft-i18n; it was `crates/ui-egui/src/i18n/hu.tsv`) has 2,419 entries: every key of the Russian catalog (built-in style
names, equation gallery labels, paper sizes, watermarks and greetings included) plus the citation
style names. It is written from the meaning of the English labels; no proprietary translation resources were consulted. The catalog
format has no plural forms, and Hungarian doesn't need them: a noun after a number stays singular
@@ -70,6 +70,7 @@ Hungarian proofing. A native-speaker review of the catalog is welcome.
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | trivial | The catalog moved to storytold/craft-i18n (`locales/hu/wordcraft.tsv`) |
| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed |
| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed |
| 2026-10-23 | trivial | The interface audit lists Hungarian strings the catalog lacks; a missing entry doesn't fail the audit |
+10 -5
View File
@@ -1,9 +1,11 @@
# Localization parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** trivial (interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
Interface languages, script support and proofing per language. How translation works (catalog
format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`.
Interface languages, script support and proofing per language. How translation works (adding a
language, the embedded catalogs): `crates/ui-egui/src/i18n/mod.rs`. The catalogs, their format and
the clean-room rule live in [storytold/craft-i18n](https://github.com/storytold/craft-i18n)
(`locales/<locale>/wordcraft.tsv` and the shared `common.tsv`).
**Dimension: ~29% (estimated; 28% before Italian proofing, 27% before Portuguese proofing, 25% before Polish and per-run proofing languages), 70–115 h to full
for the twelve key languages** plus ~45 h for the other ~17 languages Word ships, plus
@@ -25,8 +27,10 @@ native-speaker review (human).
build. `cargo xtask i18n-report`, part of `cargo xtask ci`, prints per language the interface
strings its catalog lacks: everything the interface audit saw looked up, the ribbon tabs, command
labels and ribbon locations, the File › Home greetings, disabled-control reasons and the equation
gallery. The catalog checks apply to every language alike: format (the file parses, no duplicate
keys, placeholders match the English, whitespace, ellipses). Font tests check that the bundled faces have a glyph for each catalog's letters.
gallery. The catalogs are embedded at build time from craft-i18n (`CRAFT_I18N_DIR`, the commit
in `craft-i18n.rev`; a build without it is English only), and craft-i18n's checker applies the
format checks to every language alike (rows parse, no duplicate keys, placeholders match the
English, plural forms, ellipses); the report also lists rows the crate skips and strings kept in English. Font tests check that the bundled faces have a glyph for each catalog's letters.
Tests of the lookup code (English fallback, contexts, placeholder templates, built-in style names,
the per-thread language, command search) use small inline catalogs or the pseudo-language, so
rewording a translation never fails a test.
@@ -215,6 +219,7 @@ WordCraft keeps 2,54 cm).
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | minor | Interface catalogs moved to storytold/craft-i18n (`locales/<locale>/wordcraft.tsv`), looked up through craft-libs' `craft-i18n` crate and embedded from the pinned commit (`craft-i18n.rev`); strings other Crafting Apps already translate in the shared catalog now show translated; the "Open" paragraph-spacing preset no longer shows the file verb |
| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed |
| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed |
| 2026-10-23 | trivial | Interface audit: strings a catalog lacks are printed per language, for every language, instead of failing for Russian, Hungarian, Turkish and Bulgarian |
+3 -2
View File
@@ -1,6 +1,6 @@
# Slovenian interface
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-23 · **Change:** trivial (interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: catalog completed to every key of every other catalog, 2,614 entries) · **Target:** WordCraft 0.7.0
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-23 · **Change:** trivial (the catalog moved to storytold/craft-i18n, `locales/sl/wordcraft.tsv`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: catalog completed to every key of every other catalog, 2,614 entries) · **Target:** WordCraft 0.7.0
Choose **File › Options › Interface language › Slovenščina**, or follow a Slovenian system display
language (`sl-SI`, `sl_SI.UTF-8`). The catalog code is `sl`; `ui.language` with `{"value":"sl"}`
@@ -8,7 +8,7 @@ selects it explicitly, and `WORDCRAFT_LANGUAGE=sl-SI` does the same for a headle
launch while the preference is `auto`. Document text, command ids, CLI/MCP/control responses and
file paths do not change.
`crates/ui-egui/src/i18n/sl.tsv` has 2,614 entries: every key of every other catalog (built-in
craft-i18n's `locales/sl/wordcraft.tsv` (storytold/craft-i18n; it was `crates/ui-egui/src/i18n/sl.tsv`) has 2,614 entries: every key of every other catalog (built-in
style names, equation gallery labels, paper sizes, watermarks, greetings, citation style names and
native print settings included). It is written from the meaning of the English labels; no proprietary translation
resources were consulted.
@@ -66,6 +66,7 @@ is welcome.
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | trivial | The catalog moved to storytold/craft-i18n (`locales/sl/wordcraft.tsv`) |
| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed |
| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed |
| 2026-10-15 | trivial | Catalog completed to every key of every other catalog (2,614 entries) |
+3 -2
View File
@@ -1,6 +1,6 @@
# Swedish interface
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-23 · **Change:** trivial (interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: the interface audit lists missing Swedish entries instead of failing; previously minor: Swedish interface catalog and regression checks) · **Target:** WordCraft 0.6.1
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-23 · **Change:** trivial (the catalog moved to storytold/craft-i18n, `locales/sv/wordcraft.tsv`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: the interface audit lists missing Swedish entries instead of failing; previously minor: Swedish interface catalog and regression checks) · **Target:** WordCraft 0.6.1
Choose **File › Options › Interface language › Svenska**, or follow a Swedish system display
language (`sv-SE`, `sv_SE.UTF-8`, and `sv-FI` for Swedish in Finland). The catalog code is `sv`;
@@ -9,7 +9,7 @@ same for a headless screenshot or a launch while the preference is `auto`. The c
the other interface settings. Document text, command ids, CLI/MCP/control responses and file paths
do not change.
`crates/ui-egui/src/i18n/sv.tsv` has 2,504 entries: every key of the Spanish reference catalog and
craft-i18n's `locales/sv/wordcraft.tsv` (storytold/craft-i18n; it was `crates/ui-egui/src/i18n/sv.tsv`) has 2,504 entries: every key of the Spanish reference catalog and
of the Bulgarian one (built-in style names, equation gallery labels, paper sizes, watermarks and
citation styles included), plus 44 newer strings no other catalog has yet: 23 that the interface
audit finds and 21 that the code sends through translation outside the audit's reach (native print
@@ -91,6 +91,7 @@ Swedish proofing.
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | trivial | The catalog moved to storytold/craft-i18n (`locales/sv/wordcraft.tsv`) |
| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed |
| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed |
| 2026-10-23 | trivial | The interface audit lists Swedish strings the catalog lacks; a missing entry doesn't fail the audit |
+3 -2
View File
@@ -1,6 +1,6 @@
# Turkish interface
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously minor: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Turkish interface catalog and regression checks) · **Target:** WordCraft 0.5.0
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (the catalog moved to storytold/craft-i18n, `locales/tr/wordcraft.tsv`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously minor: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Turkish interface catalog and regression checks) · **Target:** WordCraft 0.5.0
Choose **File › Options › Interface language › Türkçe**, or follow a Turkish system display language
(`tr-TR`, `tr-CY`, `tr_TR.UTF-8`). The catalog code is `tr`; `ui.language` with `{"value":"tr"}`
@@ -8,7 +8,7 @@ selects it explicitly, and `WORDCRAFT_LANGUAGE=tr-TR` does the same for a headle
launch while the preference is `auto`. The choice is saved with the other interface settings.
Document text, command ids, CLI/MCP/control responses and file paths do not change.
`crates/ui-egui/src/i18n/tr.tsv` has all 2,325 entries of the Russian catalog (the same
craft-i18n's `locales/tr/wordcraft.tsv` (storytold/craft-i18n; it was `crates/ui-egui/src/i18n/tr.tsv`) has all 2,325 entries of the Russian catalog (the same
keys). It covers all ribbon commands, dialogs, built-in style names, equation and math gallery
structures, watermark presets, paper sizes, and rendered preview tiles. It is written from the
meaning of the English labels; no proprietary translation resources were consulted. The catalog
@@ -60,6 +60,7 @@ checks (reduplications, conjunction commas, capitalization), and syllable hyphen
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | trivial | The catalog moved to storytold/craft-i18n (`locales/tr/wordcraft.tsv`) |
| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed |
| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed |
| 2026-10-13 | minor | Turkish proofing added: spelling, suggestions, grammar rules and TDK syllable hyphenation |
@@ -43,6 +43,9 @@ modules:
env:
CARGO_HOME: /run/build/wordcraft/cargo
CARGO_NET_OFFLINE: 'true'
# The interface translations (the craft-i18n source below), embedded by build.rs.
CRAFT_I18N_DIR: /run/build/wordcraft/craft-i18n
CRAFT_I18N_REQUIRED: '1'
build-commands:
- cargo --offline build --release --locked -p wordcraft -p wordcraft-cli
- install -Dm755 target/release/wordcraft /app/bin/wordcraft
@@ -64,4 +67,9 @@ modules:
- corpus
- build-flatpak
- .flatpak-builder
# The interface translations at the commit in craft-i18n.rev (a test keeps the two equal).
- type: git
url: https://github.com/storytold/craft-i18n
commit: 04600cbb1508f1b15927ec574a5e32e5f376fd8b
dest: craft-i18n
- cargo-sources.json