Skip to main content

sofie_engine/translations/
conventions.rs

1use sofie_core::DisplayZone;
2use sofie_values::{Instant, Money};
3
4use crate::escape::push_escaped;
5use crate::render::push_display;
6
7/// Keeps an amount and its currency on one line.
8const NO_BREAK_SPACE: char = '\u{a0}';
9
10const MONTHS: [&str; 12] = [
11    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
12];
13
14#[derive(Debug)]
15pub(crate) struct Conventions {
16    decimal: char,
17    group: Option<char>,
18    money: Placement,
19    days: DayOrder,
20}
21
22#[derive(Debug)]
23enum Placement {
24    /// `1234.50 EUR`
25    CodeAfter,
26    /// `€1,234.50`
27    SymbolBefore,
28    /// `1.234,50 €`
29    SymbolAfter,
30}
31
32#[derive(Debug)]
33enum DayOrder {
34    /// `2026-10-11`
35    Iso,
36    /// `Oct 11, 2026`
37    MonthNameFirst,
38    /// `11.10.2026`
39    DayFirstDotted,
40}
41
42pub(crate) const NEUTRAL: Conventions = Conventions {
43    decimal: '.',
44    group: None,
45    money: Placement::CodeAfter,
46    days: DayOrder::Iso,
47};
48
49const ENGLISH: Conventions = Conventions {
50    decimal: '.',
51    group: Some(','),
52    money: Placement::SymbolBefore,
53    days: DayOrder::MonthNameFirst,
54};
55
56const GERMAN: Conventions = Conventions {
57    decimal: ',',
58    group: Some('.'),
59    money: Placement::SymbolAfter,
60    days: DayOrder::DayFirstDotted,
61};
62
63const TURKISH: Conventions = Conventions {
64    decimal: ',',
65    group: Some('.'),
66    money: Placement::SymbolBefore,
67    days: DayOrder::DayFirstDotted,
68};
69
70/// By language alone: regional differences within one are not modelled yet.
71pub(crate) fn for_language(language: &str) -> &'static Conventions {
72    match language {
73        "en" => &ENGLISH,
74        "de" => &GERMAN,
75        "tr" => &TURKISH,
76        _ => &NEUTRAL,
77    }
78}
79
80impl Conventions {
81    /// The same rules as data, which a client area's runtime formats with.
82    #[cfg(feature = "clients")]
83    pub(crate) fn describe(&self) -> serde_json::Value {
84        serde_json::json!({
85            "decimal": self.decimal.to_string(),
86            "group": self.group.map(|group| group.to_string()),
87            "money": match self.money {
88                Placement::CodeAfter => "code-after",
89                Placement::SymbolBefore => "symbol-before",
90                Placement::SymbolAfter => "symbol-after",
91            },
92            "days": match self.days {
93                DayOrder::Iso => "iso",
94                DayOrder::MonthNameFirst => "month-name-first",
95                DayOrder::DayFirstDotted => "day-first-dotted",
96            },
97        })
98    }
99
100    /// A number in exponent form is written as it is.
101    pub(crate) fn write_number(&self, out: &mut String, plain: &str) {
102        if plain.contains(['e', 'E']) {
103            out.push_str(plain);
104            return;
105        }
106        let (sign, digits) = split_sign(plain);
107        out.push_str(sign);
108        let (whole, fraction) = digits
109            .split_once('.')
110            .map_or((digits, None), |(whole, fraction)| (whole, Some(fraction)));
111        self.write_grouped(out, whole);
112        if let Some(fraction) = fraction {
113            out.push(self.decimal);
114            out.push_str(fraction);
115        }
116    }
117
118    fn write_grouped(&self, out: &mut String, whole: &str) {
119        let Some(group) = self.group else {
120            out.push_str(whole);
121            return;
122        };
123        for (index, digit) in whole.char_indices() {
124            if index > 0 && (whole.len() - index).is_multiple_of(3) {
125                out.push(group);
126            }
127            out.push(digit);
128        }
129    }
130
131    /// A currency code is three uppercase letters by construction, so nothing here needs escaping.
132    pub(crate) fn write_money(&self, out: &mut String, amount: Money, scratch: &mut String) {
133        let currency = amount.currency();
134        let code = currency.as_str();
135        scratch.clear();
136        amount.write_major(scratch);
137        match self.money {
138            Placement::CodeAfter => {
139                self.write_number(out, scratch);
140                out.push(' ');
141                out.push_str(code);
142            }
143            Placement::SymbolBefore => {
144                let (sign, digits) = split_sign(scratch);
145                out.push_str(sign);
146                if let Some(symbol) = symbol(code) {
147                    out.push_str(symbol);
148                } else {
149                    out.push_str(code);
150                    out.push(NO_BREAK_SPACE);
151                }
152                self.write_number(out, digits);
153            }
154            Placement::SymbolAfter => {
155                self.write_number(out, scratch);
156                out.push(NO_BREAK_SPACE);
157                out.push_str(symbol(code).unwrap_or(code));
158            }
159        }
160    }
161
162    /// Only the text's shape is looked at; anything else is written escaped.
163    pub(crate) fn write_date(&self, out: &mut String, text: &str, scratch: &mut String) {
164        if is_day(text) {
165            self.write_day(out, text);
166            return;
167        }
168        if let Some((day, minute)) = wall_clock(text) {
169            self.write_day_and_minute(out, day, minute);
170            return;
171        }
172        let Ok(instant) = Instant::parse(text) else {
173            push_escaped(out, text);
174            return;
175        };
176        self.write_instant(out, instant, DisplayZone::current(), scratch);
177    }
178
179    pub(crate) fn write_instant(
180        &self,
181        out: &mut String,
182        instant: Instant,
183        zone: &DisplayZone,
184        scratch: &mut String,
185    ) {
186        scratch.clear();
187        push_display(scratch, zone.wall_clock(instant));
188        let (Some(day), Some(minute)) = (scratch.get(..10), scratch.get(11..16)) else {
189            out.push_str(scratch);
190            return;
191        };
192        self.write_day_and_minute(out, day, minute);
193        out.push(' ');
194        if let Some(offset) = zone.repeated_offset(instant) {
195            out.push_str(&offset);
196            out.push(' ');
197        }
198        push_escaped(out, zone.name());
199    }
200
201    /// On its own wall clock, so its zone is named rather than UTC.
202    pub(crate) fn write_local_date_time(&self, out: &mut String, local: &str, zone: &str) {
203        match (local.get(..10), local.get(10..11), local.get(11..16)) {
204            (Some(day), Some("T"), Some(minute)) if is_day(day) && is_minute(minute) => {
205                self.write_day_and_minute(out, day, minute);
206            }
207            _ => push_escaped(out, local),
208        }
209        out.push(' ');
210        push_escaped(out, zone);
211    }
212
213    fn write_day_and_minute(&self, out: &mut String, day: &str, minute: &str) {
214        self.write_day(out, day);
215        out.push_str(match self.days {
216            DayOrder::MonthNameFirst => ", ",
217            DayOrder::Iso | DayOrder::DayFirstDotted => " ",
218        });
219        out.push_str(minute);
220    }
221
222    fn write_day(&self, out: &mut String, iso: &str) {
223        let parts = (iso.get(..4), iso.get(5..7), iso.get(8..10));
224        let (Some(year), Some(month), Some(day), 10) = (parts.0, parts.1, parts.2, iso.len())
225        else {
226            out.push_str(iso);
227            return;
228        };
229        match self.days {
230            DayOrder::Iso => out.push_str(iso),
231            DayOrder::MonthNameFirst => {
232                let month_name = month
233                    .parse::<usize>()
234                    .ok()
235                    .and_then(|number| MONTHS.get(number.checked_sub(1)?))
236                    .unwrap_or(&"");
237                out.push_str(month_name);
238                out.push(' ');
239                out.push_str(day.trim_start_matches('0'));
240                out.push_str(", ");
241                out.push_str(year);
242            }
243            DayOrder::DayFirstDotted => {
244                for part in [day, ".", month, ".", year] {
245                    out.push_str(part);
246                }
247            }
248        }
249    }
250}
251
252fn is_day(text: &str) -> bool {
253    text.len() == 10
254        && text.bytes().enumerate().all(|(index, byte)| match index {
255            4 | 7 => byte == b'-',
256            _ => byte.is_ascii_digit(),
257        })
258}
259
260/// A date-time without an offset (a `LocalDateTime`'s `local`) names no instant, so it is
261/// written as it reads rather than in the display zone.
262fn wall_clock(text: &str) -> Option<(&str, &str)> {
263    let (day, rest) = (text.get(..10)?, text.get(10..)?);
264    let minute = rest.strip_prefix('T')?.get(..5)?;
265    let seconds = rest.get(6..)?;
266    let bare = seconds
267        .bytes()
268        .all(|byte| byte.is_ascii_digit() || byte == b':' || byte == b'.');
269    (is_day(day) && is_minute(minute) && bare).then_some((day, minute))
270}
271
272fn is_minute(text: &str) -> bool {
273    text.len() == 5
274        && text.bytes().enumerate().all(|(index, byte)| match index {
275            2 => byte == b':',
276            _ => byte.is_ascii_digit(),
277        })
278}
279
280fn split_sign(plain: &str) -> (&str, &str) {
281    plain
282        .strip_prefix('-')
283        .map_or(("", plain), |digits| ("-", digits))
284}
285
286/// Only symbols that name one currency wherever they are read.
287/// The currencies written with a symbol rather than their code.
288pub const CURRENCY_SYMBOLS: [(&str, &str); 5] = [
289    ("EUR", "€"),
290    ("USD", "$"),
291    ("GBP", "£"),
292    ("JPY", "¥"),
293    ("TRY", "₺"),
294];
295
296fn symbol(code: &str) -> Option<&'static str> {
297    CURRENCY_SYMBOLS
298        .iter()
299        .find(|(known, _)| *known == code)
300        .map(|(_, symbol)| *symbol)
301}
302
303#[cfg(test)]
304mod tests {
305    use sofie_core::DisplayZone;
306    use sofie_values::{Currency, Instant, Money};
307
308    use super::{Conventions, NEUTRAL, for_language};
309
310    fn number(conventions: &Conventions, plain: &str) -> String {
311        let mut out = String::new();
312        conventions.write_number(&mut out, plain);
313        out
314    }
315
316    fn money(language: &str, minor: i64, code: &str) -> String {
317        let mut out = String::new();
318        let amount = Money::new(minor, Currency::parse(code).unwrap());
319        for_language(language).write_money(&mut out, amount, &mut String::new());
320        out
321    }
322
323    fn date(language: &str, text: &str) -> String {
324        let mut out = String::new();
325        for_language(language).write_date(&mut out, text, &mut String::new());
326        out
327    }
328
329    #[test]
330    fn groups_and_separates_numbers_by_language() {
331        assert_eq!(number(for_language("en"), "-1234567.25"), "-1,234,567.25");
332        assert_eq!(number(for_language("de"), "1234.5"), "1.234,5");
333        assert_eq!(number(for_language("tr"), "123"), "123");
334        assert_eq!(number(&NEUTRAL, "1234.5"), "1234.5");
335        assert_eq!(number(for_language("en"), "1e300"), "1e300");
336    }
337
338    #[test]
339    fn places_the_currency_by_language() {
340        assert_eq!(money("en", 123_450, "EUR"), "€1,234.50");
341        assert_eq!(money("en", -5, "USD"), "-$0.05");
342        assert_eq!(money("en", 1500, "CHF"), "CHF\u{a0}15.00");
343        assert_eq!(money("de", 123_450, "EUR"), "1.234,50\u{a0}€");
344        assert_eq!(money("tr", 123_450, "TRY"), "₺1.234,50");
345        assert_eq!(money("fr", 123_450, "EUR"), "1234.50 EUR");
346    }
347
348    #[test]
349    fn writes_days_and_instants_by_language() {
350        assert_eq!(date("en", "2026-10-01"), "Oct 1, 2026");
351        assert_eq!(date("de", "2026-10-01"), "01.10.2026");
352        assert_eq!(date("fr", "2026-10-01"), "2026-10-01");
353        assert_eq!(
354            date("en", "2026-10-11T23:30:00+03:00"),
355            "Oct 11, 2026, 20:30 UTC"
356        );
357        assert_eq!(date("tr", "2026-10-11T09:05:59.5Z"), "11.10.2026 09:05 UTC");
358        assert_eq!(date("en", "soon <b>"), "soon &lt;b&gt;");
359    }
360
361    #[test]
362    fn writes_an_instant_on_the_display_zone_s_wall_clock_naming_the_zone() {
363        let zone = DisplayZone::named("Europe/Istanbul").unwrap();
364        let instant = Instant::parse("2026-10-11T22:30:00Z").unwrap();
365        let mut out = String::new();
366
367        for_language("en").write_instant(&mut out, instant, &zone, &mut String::new());
368
369        assert_eq!(out, "Oct 12, 2026, 01:30 Europe/Istanbul");
370    }
371
372    #[test]
373    fn writes_the_offset_before_the_zone_where_the_wall_clock_repeats() {
374        let zone = DisplayZone::named("Europe/Berlin").unwrap();
375        let written = |at: &str| {
376            let mut out = String::new();
377            let instant = Instant::parse(at).unwrap();
378            for_language("de").write_instant(&mut out, instant, &zone, &mut String::new());
379            out
380        };
381
382        assert_eq!(
383            written("2026-10-25T00:30:00Z"),
384            "25.10.2026 02:30 +02:00 Europe/Berlin"
385        );
386        assert_eq!(
387            written("2026-10-25T01:30:00Z"),
388            "25.10.2026 02:30 +01:00 Europe/Berlin"
389        );
390        assert_eq!(
391            written("2026-10-25T02:30:00Z"),
392            "25.10.2026 03:30 Europe/Berlin"
393        );
394    }
395
396    #[test]
397    fn a_date_time_without_an_offset_is_written_on_its_own_wall_clock() {
398        assert_eq!(date("en", "2026-10-20T09:30:00"), "Oct 20, 2026, 09:30");
399        assert_eq!(date("de", "2026-10-20T09:30"), "20.10.2026 09:30");
400    }
401}