Skip to main content

long_screen/engine/
catalog.rs

1//! The zh and en diagnostic and progress messages, and the formatter that fills in their `{{name}}` placeholders;
2//! also the display names of regions and canvases ([`display_name()`]).
3//!
4//! The catalogues are the `diag`, `progress` and `names` sections of `src/i18n/{zh,en}`, exported to
5//! `catalog/*.json` by `scripts/export-catalog.ts` (`deno task export-catalog`); `tests/unit/catalog-export.test.ts`
6//! fails when the checked-in JSON no longer matches the TypeScript. [`format()`] reproduces what the page's `t()`
7//! (i18next 26 with `escapeValue: false`) returns for the same key and values, so a diagnostic formatted natively
8//! reads exactly like the one the page persists. The export refuses catalogue features this formatter does not
9//! implement (nesting, formatters, unescaped placeholders, plural keys), so the two cannot drift apart silently.
10
11use serde_json::Value;
12use std::sync::OnceLock;
13
14/// A catalogue language. zh is the language persisted rows are written in and the fallback for a missing en key.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum Locale {
17    Zh,
18    En,
19}
20
21impl Locale {
22    fn code(self) -> &'static str {
23        match self {
24            Locale::Zh => "zh",
25            Locale::En => "en",
26        }
27    }
28}
29
30/// One interpolation value, rendered the way JavaScript's `String(value)` renders it.
31#[derive(Clone, Debug, PartialEq)]
32pub enum Param<'a> {
33    Str(&'a str),
34    Int(i64),
35    Num(f64),
36    Bool(bool),
37}
38
39impl Param<'_> {
40    fn render(&self) -> String {
41        match self {
42            Param::Str(s) => (*s).to_string(),
43            Param::Int(n) => n.to_string(),
44            Param::Num(n) => js_number(*n),
45            Param::Bool(b) => b.to_string(),
46        }
47    }
48}
49
50fn catalogue(locale: Locale) -> &'static Value {
51    static ZH: OnceLock<Value> = OnceLock::new();
52    static EN: OnceLock<Value> = OnceLock::new();
53    let (cell, text) = match locale {
54        Locale::Zh => (&ZH, include_str!("../../catalog/zh.json")),
55        Locale::En => (&EN, include_str!("../../catalog/en.json")),
56    };
57    cell.get_or_init(|| serde_json::from_str(text).expect("embedded catalogue is valid JSON"))
58}
59
60/// The text stored under a dotted `key` (e.g. `diag.GRAPH_RESIDUAL.message`), without falling back to zh.
61pub fn lookup(locale: Locale, key: &str) -> Option<&'static str> {
62    find(catalogue(locale), key).and_then(Value::as_str)
63}
64
65/// The message for `key` in `locale` with `params` filled in, exactly as the page's `t(key, params)` would return it:
66/// an en key that is missing falls back to zh, a key that is missing everywhere comes back as the key itself, and a
67/// placeholder whose name is not among `params` stays in the text as written.
68pub fn format(locale: Locale, key: &str, params: &[(&str, Param)]) -> String {
69    format_in(
70        catalogue(locale),
71        catalogue(Locale::Zh),
72        locale,
73        key,
74        params,
75    )
76}
77
78/// A region or canvas name in `locale`, for display. Persisted names are a fixed zh vocabulary (`names` in the
79/// catalogue: 固定界面, 内容画布 N, …) whatever the display language, so rows and fingerprints do not depend on it;
80/// this translates one the way the page's `displayName` (src/i18n/names.ts) does: the base name, then an optional
81/// ` · 未定位片段 N` (unplaced fragment) suffix, then an optional ` · 保留外框` (framed) suffix, each on its own. A
82/// name outside the vocabulary is kept as stored, and zh returns every name unchanged.
83pub fn display_name(locale: Locale, name: &str) -> String {
84    if locale == Locale::Zh {
85        return name.to_string();
86    }
87    let canonical = |key: &str| {
88        lookup(Locale::Zh, &format!("names.{key}")).expect("the catalogue has the names section")
89    };
90    let mut suffixes = Vec::new();
91    let mut rest = name;
92    if let Some(base) = rest
93        .strip_suffix(canonical("framed"))
94        .and_then(|r| r.strip_suffix(NAME_SEPARATOR))
95    {
96        rest = base;
97        suffixes.push(format(locale, "names.framed", &[]));
98    }
99    if let Some((base, tail)) = rest.rsplit_once(NAME_SEPARATOR) {
100        if let Some(n) = numbered(canonical("fragment"), tail) {
101            rest = base;
102            suffixes.insert(0, format(locale, "names.fragment", &[("n", Param::Str(n))]));
103        }
104    }
105    let base = ["fixed", "divider", "unassigned", "ignored"]
106        .into_iter()
107        .find(|key| canonical(key) == rest)
108        .map(|key| format(locale, &format!("names.{key}"), &[]))
109        .or_else(|| {
110            ["contentCanvas", "contentRegion"]
111                .into_iter()
112                .find_map(|key| {
113                    numbered(canonical(key), rest)
114                        .map(|n| format(locale, &format!("names.{key}"), &[("n", Param::Str(n))]))
115                })
116        })
117        .unwrap_or_else(|| rest.to_string());
118    std::iter::once(base)
119        .chain(suffixes)
120        .collect::<Vec<_>>()
121        .join(NAME_SEPARATOR)
122}
123
124/// What joins a base name and its suffixes.
125const NAME_SEPARATOR: &str = " · ";
126
127/// The number in `name` if it is `template` (`内容画布 {{n}}`) with `{{n}}` replaced by one or more ASCII digits.
128fn numbered<'a>(template: &str, name: &'a str) -> Option<&'a str> {
129    let (before, after) = template.split_once("{{n}}")?;
130    let n = name.strip_prefix(before)?.strip_suffix(after)?;
131    (!n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).then_some(n)
132}
133
134fn find<'v>(root: &'v Value, key: &str) -> Option<&'v Value> {
135    key.split('.').try_fold(root, |node, part| node.get(part))
136}
137
138fn format_in(
139    primary: &Value,
140    fallback: &Value,
141    locale: Locale,
142    key: &str,
143    params: &[(&str, Param)],
144) -> String {
145    match find(primary, key).or_else(|| find(fallback, key)) {
146        Some(Value::String(text)) => interpolate(text, params),
147        // i18next's wording for a key that names a whole section instead of one message.
148        Some(Value::Object(_)) => format!(
149            "key '{key} ({})' returned an object instead of string.",
150            locale.code()
151        ),
152        _ => key.to_string(),
153    }
154}
155
156/// i18next allows at most this many substitutions per string (`maxReplaces`).
157const MAX_REPLACES: usize = 1000;
158
159/// i18next's `Interpolator.interpolate` for `{{name}}` with `skipOnVariables` (its default): scan left to right for
160/// `{{…}}` (one or more characters, no line break), trim the name, and replace the FIRST occurrence of that
161/// placeholder text in the whole string — not necessarily the one just found — then continue scanning after the
162/// substituted value, so a value is never interpolated again. A name missing from `params` is left in place.
163fn interpolate(text: &str, params: &[(&str, Param)]) -> String {
164    let mut out = text.to_string();
165    let mut cursor = 0;
166    for _ in 0..MAX_REPLACES {
167        let Some((start, end)) = next_placeholder(&out, cursor) else {
168            break;
169        };
170        let placeholder = out[start..end].to_string();
171        let name = placeholder[2..placeholder.len() - 2].trim();
172        let Some((_, value)) = params.iter().find(|(n, _)| *n == name) else {
173            cursor = end;
174            continue;
175        };
176        let value = value.render();
177        let first = out
178            .find(&placeholder)
179            .expect("the placeholder was just found");
180        out.replace_range(first..first + placeholder.len(), &value);
181        cursor = start + value.len();
182    }
183    out
184}
185
186/// The first match of i18next's `/{{(.+?)}}/g` at or after byte `from`: the leftmost `{{` whose shortest following
187/// non-empty run without a line terminator ends in `}}`.
188fn next_placeholder(text: &str, from: usize) -> Option<(usize, usize)> {
189    let bytes = text.as_bytes();
190    let mut start = from;
191    while start + 5 <= bytes.len() {
192        if bytes[start] == b'{' && bytes[start + 1] == b'{' {
193            let body = &text[start + 2..];
194            let mut offset = 0;
195            for (i, c) in body.char_indices() {
196                if matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') {
197                    break;
198                }
199                if i > 0 && body[i..].starts_with("}}") {
200                    offset = i;
201                    break;
202                }
203            }
204            if offset > 0 {
205                return Some((start, start + 2 + offset + 2));
206            }
207        }
208        start += 1;
209        while start < bytes.len() && !text.is_char_boundary(start) {
210            start += 1;
211        }
212    }
213    None
214}
215
216/// JavaScript's `Number.prototype.toString()` (radix 10): shortest round-trip digits, plain notation for exponents
217/// in [-7, 21), exponent notation (`1e+21`, `1e-7`) outside it, `-0` as `0`.
218pub fn js_number(value: f64) -> String {
219    if value.is_nan() {
220        return "NaN".into();
221    }
222    if value.is_infinite() {
223        return if value > 0.0 { "Infinity" } else { "-Infinity" }.into();
224    }
225    if value == 0.0 {
226        return "0".into();
227    }
228    let sign = if value < 0.0 { "-" } else { "" };
229    // `{:e}` prints the shortest round-trip digits as d.ddde±x.
230    let scientific = format!("{:e}", value.abs());
231    let (mantissa, exponent) = scientific
232        .split_once('e')
233        .expect("`{:e}` always has an exponent");
234    let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
235    let k = digits.len() as i32;
236    // The value is 0.digits × 10^n, as in the ECMAScript algorithm.
237    let n = exponent
238        .parse::<i32>()
239        .expect("`{:e}` exponent is an integer")
240        + 1;
241    let body = if k <= n && n <= 21 {
242        format!("{digits}{}", "0".repeat((n - k) as usize))
243    } else if 0 < n && n <= 21 {
244        format!("{}.{}", &digits[..n as usize], &digits[n as usize..])
245    } else if -6 < n && n <= 0 {
246        format!("0.{}{digits}", "0".repeat((-n) as usize))
247    } else {
248        let e = n - 1;
249        let e = if e >= 0 {
250            format!("+{e}")
251        } else {
252            e.to_string()
253        };
254        if k == 1 {
255            format!("{digits}e{e}")
256        } else {
257            format!("{}.{}e{e}", &digits[..1], &digits[1..])
258        }
259    };
260    format!("{sign}{body}")
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use serde_json::json;
267
268    fn sample() -> (Value, Value) {
269        let zh = json!({
270            "a": "x={{x}} y={{y}}!",
271            "only_zh": "仅中文 {{n}}",
272            "section": { "leaf": "n {{n}}" },
273            "spaced": "k {{ k }} {{k}}",
274            "twice": "{{x}}{{x}}",
275            "broken": "{{a\nb}} {{c}}",
276            "empty": "{{}} {{c}}",
277        });
278        let en = json!({ "a": "en x={{x}}", "section": { "leaf": "en n {{n}}" } });
279        (zh, en)
280    }
281
282    fn zh(key: &str, params: &[(&str, Param)]) -> String {
283        let (zh, _) = sample();
284        format_in(&zh, &zh, Locale::Zh, key, params)
285    }
286
287    #[test]
288    fn missing_key_comes_back_as_the_key() {
289        assert_eq!(zh("no.such.key", &[]), "no.such.key");
290        assert_eq!(zh("a.deeper", &[]), "a.deeper");
291    }
292
293    #[test]
294    fn a_section_key_reads_like_i18next() {
295        assert_eq!(
296            zh("section", &[]),
297            "key 'section (zh)' returned an object instead of string."
298        );
299    }
300
301    #[test]
302    fn missing_param_leaves_the_placeholder() {
303        assert_eq!(zh("a", &[("x", Param::Int(1))]), "x=1 y={{y}}!");
304        assert_eq!(zh("section.leaf", &[]), "n {{n}}");
305    }
306
307    #[test]
308    fn en_falls_back_to_zh_per_key() {
309        let (zh, en) = sample();
310        assert_eq!(
311            format_in(&en, &zh, Locale::En, "a", &[("x", Param::Int(2))]),
312            "en x=2"
313        );
314        assert_eq!(
315            format_in(&en, &zh, Locale::En, "only_zh", &[("n", Param::Int(3))]),
316            "仅中文 3"
317        );
318        assert_eq!(format_in(&en, &zh, Locale::En, "nowhere", &[]), "nowhere");
319    }
320
321    #[test]
322    fn numbers_render_like_javascript() {
323        let cases: [(f64, &str); 16] = [
324            (1.0, "1"),
325            (1.5, "1.5"),
326            (2.50, "2.5"),
327            (0.1 + 0.2, "0.30000000000000004"),
328            (1e21, "1e+21"),
329            (1e20, "100000000000000000000"),
330            (1e-7, "1e-7"),
331            (1e-6, "0.000001"),
332            (1.23e-18, "1.23e-18"),
333            (-0.0, "0"),
334            (-12.25, "-12.25"),
335            (f64::INFINITY, "Infinity"),
336            (f64::NEG_INFINITY, "-Infinity"),
337            (f64::NAN, "NaN"),
338            (123456789012.0, "123456789012"),
339            (1.5e300, "1.5e+300"),
340        ];
341        for (value, expected) in cases {
342            assert_eq!(js_number(value), expected, "{value:?}");
343            assert_eq!(
344                zh("section.leaf", &[("n", Param::Num(value))]),
345                format!("n {expected}")
346            );
347        }
348        assert_eq!(zh("section.leaf", &[("n", Param::Int(-7))]), "n -7");
349        assert_eq!(zh("section.leaf", &[("n", Param::Bool(true))]), "n true");
350    }
351
352    #[test]
353    fn substitution_follows_i18next_first_occurrence_rule() {
354        // Measured with i18next 26.4.2: the value "{{y}}" replaces {{x}}, then the scan finds that new {{y}} first.
355        assert_eq!(
356            zh("a", &[("x", Param::Str("{{y}}")), ("y", Param::Str("Y"))]),
357            "x=Y y={{y}}!"
358        );
359        assert_eq!(zh("spaced", &[("k", Param::Int(7))]), "k 7 7");
360        assert_eq!(zh("twice", &[("x", Param::Str("Q"))]), "QQ");
361        assert_eq!(
362            zh("a", &[("x", Param::Str("$&")), ("y", Param::Str("a$1b"))]),
363            "x=$& y=a$1b!"
364        );
365    }
366
367    #[test]
368    fn placeholders_need_a_name_on_one_line() {
369        assert_eq!(
370            zh("broken", &[("c", Param::Int(1)), ("a\nb", Param::Int(2))]),
371            "{{a\nb}} 1"
372        );
373        // "{{}} {{c}}" is one placeholder named "}} {{c": the non-greedy body needs one character before `}}`.
374        assert_eq!(zh("empty", &[("c", Param::Int(1))]), "{{}} {{c}}");
375        assert_eq!(zh("empty", &[("}} {{c", Param::Int(1))]), "1");
376    }
377
378    #[test]
379    fn real_catalogues_are_embedded() {
380        assert_eq!(
381            lookup(Locale::Zh, "progress.scanFrame"),
382            Some("一帧一帧看录屏,找出哪些区域在动。")
383        );
384        assert!(lookup(Locale::En, "progress.scanFrame").is_some());
385        assert!(lookup(Locale::Zh, "diag.MODEL_ASSUMPTIONS").is_none());
386        let message = format(
387            Locale::Zh,
388            "diag.MEMORY_BUDGET_RAISED.message",
389            &[
390                ("from", Param::Int(12)),
391                ("to", Param::Int(30)),
392                ("mb", Param::Int(9)),
393            ],
394        );
395        assert_eq!(
396            message,
397            "瓦片缓存从预算允许的 12 块提高到 30 块(约 9 MB),以容纳一帧触及的全部瓦片。"
398        );
399    }
400
401    // display_name mirrors src/i18n/names.ts::displayName. Ways it could go wrong: translating in zh (persisted
402    // names are zh already); missing one of the four fixed names or the two numbered ones; reading a numbered name
403    // whose number is not ASCII digits (JS `\d`) or is empty; translating the fragment and framed suffixes in the
404    // wrong order, or a suffix that is not at the end; dropping a name outside the vocabulary instead of keeping it;
405    // splitting the unassigned name, which contains the separator itself.
406    #[test]
407    fn names_stay_as_stored_in_zh() {
408        for name in [
409            "内容画布 1",
410            "内容画布 1 · 未定位片段 2 · 保留外框",
411            "固定界面",
412            "",
413        ] {
414            assert_eq!(display_name(Locale::Zh, name), name);
415        }
416    }
417
418    #[test]
419    fn the_vocabulary_reads_in_english() {
420        let cases = [
421            ("固定界面", "Fixed UI"),
422            ("固定分隔界面", "Fixed divider"),
423            (
424                "未指定区域 · 屏幕坐标观察",
425                "Unassigned area · screen-space observations",
426            ),
427            ("主动忽略", "Ignored"),
428            ("内容画布 1", "Content canvas 1"),
429            ("内容画布 12", "Content canvas 12"),
430            ("内容画布 01", "Content canvas 01"),
431            ("内容区域 3", "Content region 3"),
432            ("内容画布 1 · 保留外框", "Content canvas 1 · framed"),
433            (
434                "内容画布 2 · 未定位片段 3",
435                "Content canvas 2 · unplaced fragment 3",
436            ),
437            (
438                "内容画布 1 · 未定位片段 2 · 保留外框",
439                "Content canvas 1 · unplaced fragment 2 · framed",
440            ),
441            (
442                "未指定区域 · 屏幕坐标观察 · 未定位片段 1",
443                "Unassigned area · screen-space observations · unplaced fragment 1",
444            ),
445        ];
446        for (name, english) in cases {
447            assert_eq!(display_name(Locale::En, name), english, "{name}");
448        }
449    }
450
451    #[test]
452    fn names_outside_the_vocabulary_keep_what_they_can() {
453        let cases = [
454            ("Header", "Header"),
455            ("", ""),
456            ("内容画布 x", "内容画布 x"),
457            ("内容画布 ", "内容画布 "),
458            ("内容画布 ١", "内容画布 ١"),
459            ("内容画布 1 extra", "内容画布 1 extra"),
460            ("内容画布 1 · 未定位片段 x", "内容画布 1 · 未定位片段 x"),
461            // Suffixes are read from the end in a fixed order: fragment, then framed.
462            (
463                "内容画布 1 · 保留外框 · 未定位片段 2",
464                "内容画布 1 · 保留外框 · unplaced fragment 2",
465            ),
466            ("Header · 未定位片段 1", "Header · unplaced fragment 1"),
467            ("保留外框", "保留外框"),
468        ];
469        for (name, english) in cases {
470            assert_eq!(display_name(Locale::En, name), english, "{name}");
471        }
472    }
473}