BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / i18n.rs

i18n.rs

i18n.rs 14.1 KiB Download Raw
1//! UI language (Tim t1004u): English (default) or Dutch.
2//!
3//! Source strings are Dutch; [`t`] translates any displayed text at the UI
4//! boundary through [`crate::i18n_table::TABLE`]. Entries may be format
5//! templates whose `{…}` holes are matched and translated recursively.
6use std::collections::HashMap;
7use std::path::Path;
8use std::sync::atomic::{AtomicU8, Ordering};
9use std::sync::{Mutex, OnceLock};
10
11use crate::i18n_table::TABLE;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum Lang {
15 #[default]
16 En,
17 Nl,
18}
19
20impl Lang {
21 pub const ALL: [Lang; 2] = [Lang::En, Lang::Nl];
22 pub fn code(self) -> &'static str {
23 match self {
24 Lang::En => "en",
25 Lang::Nl => "nl",
26 }
27 }
28 pub fn from_code(code: &str) -> Option<Self> {
29 Self::ALL.into_iter().find(|l| l.code() == code)
30 }
31 /// Name of the language in that language.
32 pub fn native_name(self) -> &'static str {
33 match self {
34 Lang::En => "English",
35 Lang::Nl => "Nederlands",
36 }
37 }
38}
39
40static LANG: AtomicU8 = AtomicU8::new(0);
41
42pub fn lang() -> Lang {
43 if LANG.load(Ordering::Relaxed) == 1 {
44 Lang::Nl
45 } else {
46 Lang::En
47 }
48}
49
50pub fn set_lang(lang: Lang) {
51 LANG.store(u8::from(lang == Lang::Nl), Ordering::Relaxed);
52}
53
54/// Display text in the current language.
55pub fn t(text: impl AsRef<str>) -> String {
56 tr(lang(), text.as_ref())
57}
58
59/// Display text in `lang`.
60pub fn tr(lang: Lang, text: &str) -> String {
61 match lang {
62 Lang::Nl => text.to_string(),
63 Lang::En => to_english(text),
64 }
65}
66
67struct Template {
68 /// Literal parts; holes sit between consecutive parts.
69 parts: Vec<String>,
70 holes: Vec<String>,
71 english: &'static str,
72 weight: usize,
73}
74
75struct Tables {
76 exact: HashMap<&'static str, &'static str>,
77 templates: Vec<Template>,
78}
79
80/// Split a format string into literal parts and `{…}` hole tokens.
81fn split_template(text: &str) -> (Vec<String>, Vec<String>) {
82 let mut parts = vec![String::new()];
83 let mut holes = Vec::new();
84 let mut chars = text.chars().peekable();
85 while let Some(c) = chars.next() {
86 match c {
87 '{' if chars.peek() == Some(&'{') => {
88 chars.next();
89 parts.last_mut().unwrap().push('{');
90 }
91 '}' if chars.peek() == Some(&'}') => {
92 chars.next();
93 parts.last_mut().unwrap().push('}');
94 }
95 '{' => {
96 let mut token = String::from("{");
97 for d in chars.by_ref() {
98 token.push(d);
99 if d == '}' {
100 break;
101 }
102 }
103 holes.push(token);
104 parts.push(String::new());
105 }
106 _ => parts.last_mut().unwrap().push(c),
107 }
108 }
109 (parts, holes)
110}
111
112fn tables() -> &'static Tables {
113 static T: OnceLock<Tables> = OnceLock::new();
114 T.get_or_init(|| {
115 let mut exact = HashMap::new();
116 let mut templates = Vec::new();
117 for (nl, en) in TABLE {
118 let (parts, holes) = split_template(nl);
119 if holes.is_empty() {
120 exact.insert(*nl, *en);
121 // `{{`/`}}`-free literal; also index its unescaped form.
122 continue;
123 }
124 let weight: usize = parts.iter().map(|p| p.chars().count()).sum();
125 if weight == 0 {
126 continue;
127 }
128 templates.push(Template {
129 parts,
130 holes,
131 english: en,
132 weight,
133 });
134 }
135 templates.sort_by_key(|t| std::cmp::Reverse(t.weight));
136 Tables { exact, templates }
137 })
138}
139
140/// Match `msg` against literal `parts` (holes in between, non-greedy, backtracking).
141fn match_parts<'m>(msg: &'m str, parts: &[String]) -> Option<Vec<&'m str>> {
142 let rest = msg.strip_prefix(parts[0].as_str())?;
143 if parts.len() == 1 {
144 return rest.is_empty().then(Vec::new);
145 }
146 let next = &parts[1];
147 let mut start = 0;
148 loop {
149 let found = if next.is_empty() {
150 if parts.len() == 2 {
151 Some(rest.len())
152 } else {
153 // Empty separator between two holes: not supported (ambiguous).
154 return None;
155 }
156 } else {
157 rest[start..].find(next.as_str()).map(|i| start + i)
158 };
159 let at = found?;
160 let hole = &rest[..at];
161 let mut tail_parts = parts[1..].to_vec();
162 tail_parts[0] = next.clone();
163 if let Some(mut more) = match_parts(&rest[at..], &tail_parts) {
164 let mut out = vec![hole];
165 out.append(&mut more);
166 return Some(out);
167 }
168 if next.is_empty() {
169 return None;
170 }
171 // Advance to the next occurrence (keep char boundaries).
172 start = at + next.chars().next().map_or(1, char::len_utf8);
173 if start > rest.len() {
174 return None;
175 }
176 }
177}
178
179fn to_english(text: &str) -> String {
180 static CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
181 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
182 if let Some(hit) = cache.lock().ok().and_then(|c| c.get(text).cloned()) {
183 return hit;
184 }
185 let out = translate(text, 0);
186 if let Ok(mut c) = cache.lock() {
187 if c.len() > 4096 {
188 c.clear();
189 }
190 c.insert(text.to_string(), out.clone());
191 }
192 out
193}
194
195fn translate(text: &str, depth: u32) -> String {
196 let tables = tables();
197 if let Some(en) = tables.exact.get(text) {
198 return (*en).to_string();
199 }
200 if depth > 6 || text.is_empty() {
201 return text.to_string();
202 }
203 // Multi-line text: translate per line.
204 if text.contains('\n') {
205 return text
206 .split('\n')
207 .map(|line| translate(line, depth + 1))
208 .collect::<Vec<_>>()
209 .join("\n");
210 }
211 for template in &tables.templates {
212 let Some(captures) = match_parts(text, &template.parts) else {
213 continue;
214 };
215 let translated: Vec<String> = captures.iter().map(|c| translate(c, depth + 1)).collect();
216 let (en_parts, en_holes) = split_template(template.english);
217 let mut used = vec![false; template.holes.len()];
218 let mut out = en_parts[0].clone();
219 for (k, token) in en_holes.iter().enumerate() {
220 let pick = (0..template.holes.len())
221 .find(|&i| !used[i] && template.holes[i] == *token)
222 .or_else(|| (0..template.holes.len()).find(|&i| !used[i]));
223 if let Some(i) = pick {
224 used[i] = true;
225 out.push_str(&translated[i]);
226 }
227 out.push_str(&en_parts[k + 1]);
228 }
229 return out;
230 }
231 text.to_string()
232}
233
234/// Settings file in the user config dir.
235pub const SETTINGS_FILE: &str = "settings.json";
236
237/// Read the stored language (English when missing or unreadable).
238pub fn load_language(dir: &Path) -> Lang {
239 std::fs::read(dir.join(SETTINGS_FILE))
240 .ok()
241 .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
242 .and_then(|v| v.get("language")?.as_str().and_then(Lang::from_code))
243 .unwrap_or_default()
244}
245
246/// Store the language atomically.
247pub fn save_language(dir: &Path, lang: Lang) -> Result<(), String> {
248 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
249 let bytes = serde_json::to_vec_pretty(&serde_json::json!({ "language": lang.code() }))
250 .map_err(|e| e.to_string())?;
251 let temp = dir.join(format!("{SETTINGS_FILE}.tmp-{}", std::process::id()));
252 std::fs::write(&temp, bytes).map_err(|e| e.to_string())?;
253 std::fs::rename(&temp, dir.join(SETTINGS_FILE)).map_err(|e| e.to_string())
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 fn holes(s: &str) -> Vec<String> {
261 let mut h = split_template(s).1;
262 h.sort();
263 h
264 }
265
266 #[test]
267 fn every_entry_has_both_languages_with_the_same_placeholders() {
268 let mut seen = std::collections::HashSet::new();
269 for (nl, en) in TABLE {
270 assert!(
271 !nl.is_empty() && !en.trim().is_empty(),
272 "empty entry {nl:?}"
273 );
274 assert!(seen.insert(*nl), "duplicate key {nl:?}");
275 assert_eq!(holes(nl), holes(en), "placeholders differ: {nl:?} / {en:?}");
276 }
277 }
278
279 #[test]
280 fn exact_templates_nested_and_runtime_switch() {
281 assert_eq!(tr(Lang::En, "Project laden"), "Load project");
282 assert_eq!(tr(Lang::Nl, "Project laden"), "Project laden");
283 assert_eq!(
284 tr(Lang::En, "Oude onvoltooide render verwijderd: inprogress-1"),
285 "Old unfinished render removed: inprogress-1"
286 );
287 // Nested: outer template with a translated inner message.
288 assert_eq!(
289 tr(Lang::En, "atlas: raw files/x.png: PNG groter dan limiet"),
290 "atlas: raw files/x.png: PNG larger than the limit"
291 );
292 assert_eq!(
293 tr(
294 Lang::En,
295 "voltooide render faalt controle: .ppda: ongeldige fps"
296 ),
297 "completed render fails verification: .ppda: invalid fps"
298 );
299 // Unknown text passes through.
300 assert_eq!(tr(Lang::En, "xyz 123"), "xyz 123");
301 set_lang(Lang::Nl);
302 assert_eq!(t("Opslaan"), "Opslaan");
303 set_lang(Lang::En);
304 assert_eq!(t("Opslaan"), "Save");
305 }
306
307 #[test]
308 fn settings_round_trip_and_default_english() {
309 let dir = std::env::temp_dir().join(format!("paperdoll-i18n-{}", std::process::id()));
310 let _ = std::fs::remove_dir_all(&dir);
311 assert_eq!(load_language(&dir), Lang::En);
312 save_language(&dir, Lang::Nl).unwrap();
313 assert_eq!(load_language(&dir), Lang::Nl);
314 std::fs::write(dir.join(SETTINGS_FILE), b"garbage").unwrap();
315 assert_eq!(load_language(&dir), Lang::En);
316 let _ = std::fs::remove_dir_all(dir);
317 }
318
319 /// Minimal Rust string-literal scanner for the coverage test.
320 fn literals(code: &str) -> Vec<String> {
321 let b = code.as_bytes();
322 let mut out = Vec::new();
323 let mut i = 0;
324 while i < b.len() {
325 if code[i..].starts_with("//") {
326 i = code[i..].find('\n').map_or(b.len(), |j| i + j);
327 continue;
328 }
329 if b[i] == b'\'' {
330 // char literal like '"' or '\\'
331 let rest = &code[i..];
332 let len = if rest.starts_with("'\\") { 4 } else { 3 };
333 if rest.len() >= len && rest.as_bytes()[len - 1] == b'\'' {
334 i += len;
335 continue;
336 }
337 }
338 if b[i] == b'r'
339 && (i == 0 || !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_'))
340 && code[i + 1..].starts_with(['"', '#'])
341 {
342 let hashes = code[i + 1..].chars().take_while(|c| *c == '#').count();
343 if code[i + 1 + hashes..].starts_with('"') {
344 let end = format!("\"{}", "#".repeat(hashes));
345 let start = i + 2 + hashes;
346 let j = code[start..].find(&end).map_or(b.len(), |j| start + j);
347 i = j + end.len();
348 continue;
349 }
350 }
351 if b[i] == b'"' {
352 let byte_lit = i > 0 && b[i - 1] == b'b';
353 let mut j = i + 1;
354 let mut s = String::new();
355 while j < b.len() && b[j] != b'"' {
356 if b[j] == b'\\' {
357 s.push_str(&code[j..j + 2]);
358 j += 2;
359 } else {
360 let c = code[j..].chars().next().unwrap();
361 s.push(c);
362 j += c.len_utf8();
363 }
364 }
365 if !byte_lit {
366 out.push(s);
367 }
368 i = j + 1;
369 continue;
370 }
371 i += code[i..].chars().next().map_or(1, char::len_utf8);
372 }
373 out
374 }
375
376 fn unescape(s: &str) -> String {
377 let mut out = String::new();
378 let mut chars = s.chars().peekable();
379 while let Some(c) = chars.next() {
380 if c != '\\' {
381 out.push(c);
382 continue;
383 }
384 match chars.next() {
385 Some('n') => out.push('\n'),
386 Some('t') => out.push('\t'),
387 Some('\n') => {
388 while chars.peek().is_some_and(|c| c.is_whitespace()) {
389 chars.next();
390 }
391 }
392 Some(other) => out.push(other),
393 None => {}
394 }
395 }
396 out
397 }
398
399 #[test]
400 fn every_user_visible_source_string_has_a_translation() {
401 let keys: std::collections::HashSet<&str> = TABLE.iter().map(|(nl, _)| *nl).collect();
402 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
403 let mut missing = Vec::new();
404 for entry in std::fs::read_dir(dir).unwrap() {
405 let path = entry.unwrap().path();
406 let name = path.file_name().unwrap().to_string_lossy().to_string();
407 if !name.ends_with(".rs")
408 || name == "main.rs"
409 || name == "i18n_table.rs"
410 || name == "i18n.rs"
411 {
412 continue;
413 }
414 let code = std::fs::read_to_string(&path).unwrap();
415 let code = code
416 .find("\n#[cfg(test)]\nmod tests")
417 .map_or(code.as_str(), |i| &code[..i]);
418 for lit in literals(code) {
419 let text = unescape(&lit);
420 let letters = text.chars().filter(|c| c.is_alphabetic()).count() >= 3;
421 let wordy = text.contains(' ') || text.contains(['é', 'ë', 'ï']);
422 if letters && wordy && !keys.contains(text.as_str()) {
423 missing.push(format!("{name}: {text:?}"));
424 }
425 }
426 }
427 assert!(missing.is_empty(), "untranslated:\n{}", missing.join("\n"));
428 }
429}