Skip to main content

dvb_si/text/
mod.rs

1//! DVB-SI text decoding — ETSI EN 300 468 Annex A.
2//!
3//! Covers the full Annex A Table A.3 selector set: the default Latin table
4//! (Figure A.1, an ISO 6937 superset — see `iso_6937_single`), ISO 8859-n
5//! (single-byte 0x01–0x0B and extended 0x10 forms), UCS-2 BE (0x11),
6//! KS X 1001 Korean (0x12, decoded as EUC-KR), GB-2312 Simplified Chinese
7//! (0x13, decoded via GBK which is a GB-2312 superset), Big5 Traditional
8//! Chinese (0x14), UTF-8 (0x15), and the 0x1F `encoding_type_id` escape
9//! (no ids are registered for broadcast use — yields U+FFFD). Reserved
10//! selectors (0x08, 0x0C–0x0F, 0x16–0x1E) yield U+FFFD per byte.
11//!
12//! Glyph mappings are pinned to EN 300 468 V1.19.1 (2025-02) Figure A.1
13//! "Character code table 00 - Latin alphabet with Unicode equivalents"
14//! (PDF p. 159, vendored at `specs/etsi_en_300_468_v01.19.01_dvb_si.pdf`;
15//! transcription in `dvb-si/docs/en_300_468.md`).
16//!
17//! [`DvbText`] wraps the raw wire bytes and decodes only on demand — parsing
18//! stays zero-copy; decoding happens when you call [`DvbText::decode`], `Display`,
19//! or serde:
20//!
21//! ```
22//! use dvb_si::text::{DvbText, LangCode};
23//!
24//! // Leading 0x15 is the Annex A UTF-8 selector; "café" follows.
25//! let name = DvbText::new(&[0x15, b'c', b'a', b'f', 0xC3, 0xA9]);
26//! assert_eq!(name.decode(), "café");
27//! assert_eq!(name.raw(), &[0x15, b'c', b'a', b'f', 0xC3, 0xA9]); // selector kept
28//!
29//! // A selector-less default-Latin (ISO 6937) sequence: combining acute + e → é.
30//! assert_eq!(DvbText::new(&[0xC2, b'e']).decode(), "é");
31//!
32//! // LangCode is 3 raw bytes (ISO 639-2 / ISO 3166) decoded lossily on demand.
33//! assert_eq!(LangCode(*b"fre").as_str(), "fre");
34//! ```
35
36use std::borrow::Cow;
37
38/// Decode a DVB text payload (e.g. short_event_descriptor event_name_char)
39/// into an owned UTF-8 `String`. The first byte may be a charset indicator
40/// per ETSI EN 300 468 Annex A Table A.3.
41#[must_use]
42pub fn decode_dvb_string(bytes: &[u8]) -> String {
43    if bytes.is_empty() {
44        return String::new();
45    }
46
47    let (charset, body) = split_charset(bytes);
48    let decoded = match charset {
49        Charset::Iso6937 => decode_iso_6937(body),
50        Charset::Iso8859(n) => decode_iso_8859(n, body),
51        Charset::Utf8 => String::from_utf8_lossy(body).into_owned(),
52        Charset::Ucs2Be => decode_ucs2_be(body),
53        Charset::Ksx1001 => decode_with(encoding_rs::EUC_KR, body),
54        Charset::Gb2312 => decode_with(encoding_rs::GBK, body),
55        Charset::Big5 => decode_with(encoding_rs::BIG5, body),
56        Charset::Unsupported(_indicator) => body.iter().map(|_| '\u{FFFD}').collect(),
57    };
58
59    // Annex A.1 control codes:
60    //   single-byte tables: 0x86 emphasis on, 0x87 emphasis off, 0x8A CR/LF
61    //   -> space; other C0/C1 controls are stripped.
62    //   two-byte tables (Table A.2): the same functions live at U+E086 /
63    //   U+E087 / U+E08A inside the ISO 10646 PUA; the rest of
64    //   U+E080..U+E09F is reserved for control functions and stripped.
65    decoded
66        .chars()
67        .filter_map(|c| match c as u32 {
68            0x86 | 0x87 | 0xE086 | 0xE087 => None,
69            0x8A | 0xE08A => Some(' '),
70            0x0A => Some(' '),
71            code if code < 0x20 => None,
72            code if (0x80..0xA0).contains(&code) => None,
73            code if (0xE080..0xE0A0).contains(&code) => None,
74            _ => Some(c),
75        })
76        .collect()
77}
78
79/// Convenience wrapper returning `Cow::Borrowed` for pure-ASCII input,
80/// `Cow::Owned` otherwise.
81#[must_use]
82pub fn decode(bytes: &[u8]) -> Cow<'_, str> {
83    if bytes.iter().all(|&b| b.is_ascii() && b >= 0x20) {
84        return Cow::Borrowed(std::str::from_utf8(bytes).unwrap_or(""));
85    }
86    Cow::Owned(decode_dvb_string(bytes))
87}
88
89/// Borrowed DVB-encoded text (EN 300 468 Annex A). Wraps the raw selector +
90/// body bytes; decoding happens only on [`DvbText::decode`] / `Display` /
91/// serde — never in the parse hot path.
92#[derive(Clone, Copy, PartialEq, Eq, Hash)]
93pub struct DvbText<'a>(&'a [u8]);
94
95impl<'a> DvbText<'a> {
96    /// Wrap raw Annex A bytes (charset selector included, if any).
97    #[must_use]
98    pub const fn new(raw: &'a [u8]) -> Self {
99        Self(raw)
100    }
101    /// The raw wire bytes, selector included.
102    #[must_use]
103    pub const fn raw(&self) -> &'a [u8] {
104        self.0
105    }
106    /// Decode per Annex A (Table A.3 selector + control codes). Borrows only
107    /// for selector-less printable-ASCII input; any charset selector byte
108    /// forces an owned decode.
109    #[must_use]
110    pub fn decode(&self) -> Cow<'a, str> {
111        decode(self.0)
112    }
113}
114
115impl std::ops::Deref for DvbText<'_> {
116    /// Derefs to the raw wire bytes (selector included) — `len()`/indexing are
117    /// byte counts for serialization, not decoded character counts.
118    type Target = [u8];
119    fn deref(&self) -> &[u8] {
120        self.0
121    }
122}
123
124impl std::fmt::Display for DvbText<'_> {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.write_str(&self.decode())
127    }
128}
129
130impl std::fmt::Debug for DvbText<'_> {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        write!(f, "DvbText({:?})", self.decode())
133    }
134}
135
136impl<'a> From<&'a [u8]> for DvbText<'a> {
137    fn from(raw: &'a [u8]) -> Self {
138        Self(raw)
139    }
140}
141
142#[cfg(feature = "serde")]
143impl serde::Serialize for DvbText<'_> {
144    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
145        s.serialize_str(&self.decode())
146    }
147}
148// Serialize-only: re-encoding decoded text into DVB charset bytes is lossy.
149// Structs holding DvbText derive Serialize only; re-parse from wire bytes.
150
151/// ISO 639-2 language code or ISO 3166 country code — 3 raw bytes.
152#[derive(Clone, Copy, PartialEq, Eq, Hash)]
153pub struct LangCode(pub [u8; 3]);
154
155impl LangCode {
156    /// The code as a string; lossy (U+FFFD) for non-ASCII garbage.
157    #[must_use]
158    pub fn as_str(&self) -> Cow<'_, str> {
159        String::from_utf8_lossy(&self.0)
160    }
161}
162
163impl std::ops::Deref for LangCode {
164    type Target = [u8; 3];
165    fn deref(&self) -> &[u8; 3] {
166        &self.0
167    }
168}
169
170impl std::fmt::Display for LangCode {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        f.write_str(&self.as_str())
173    }
174}
175
176impl std::fmt::Debug for LangCode {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        write!(f, "LangCode({})", self.as_str())
179    }
180}
181
182#[cfg(feature = "serde")]
183impl serde::Serialize for LangCode {
184    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
185        s.serialize_str(&self.as_str())
186    }
187}
188
189#[derive(Debug)]
190enum Charset {
191    Iso6937,
192    Iso8859(u8),
193    Utf8,
194    Ucs2Be,
195    /// KS X 1001 (selector 0x12), decoded as EUC-KR.
196    Ksx1001,
197    /// GB-2312 (selector 0x13), decoded via GBK (a GB-2312 superset).
198    Gb2312,
199    /// Big5 (selector 0x14).
200    Big5,
201    Unsupported(u8),
202}
203
204fn split_charset(bytes: &[u8]) -> (Charset, &[u8]) {
205    match bytes[0] {
206        b if b >= 0x20 => (Charset::Iso6937, bytes),
207        0x00 => (Charset::Iso6937, &bytes[1..]),
208        // Table A.3: 0x01..=0x0B map to ISO 8859-5..-15, EXCEPT 0x08 which is
209        // "reserved for future use" (there is no ISO 8859-12).
210        0x08 => (Charset::Unsupported(0x08), &bytes[1..]),
211        0x01..=0x0B => (Charset::Iso8859(bytes[0] + 4), &bytes[1..]),
212        0x10 if bytes.len() >= 3 && bytes[1] == 0x00 => (Charset::Iso8859(bytes[2]), &bytes[3..]),
213        0x11 => (Charset::Ucs2Be, &bytes[1..]),
214        0x12 => (Charset::Ksx1001, &bytes[1..]),
215        0x13 => (Charset::Gb2312, &bytes[1..]),
216        0x14 => (Charset::Big5, &bytes[1..]),
217        0x15 => (Charset::Utf8, &bytes[1..]),
218        // 0x1F: an 8-bit encoding_type_id follows (Table A.4 area); no ids are
219        // registered for broadcast text — treat the body as undecodable.
220        0x1F if bytes.len() >= 2 => (Charset::Unsupported(0x1F), &bytes[2..]),
221        other => (Charset::Unsupported(other), &bytes[1..]),
222    }
223}
224
225fn decode_iso_6937(bytes: &[u8]) -> String {
226    let mut out = String::with_capacity(bytes.len());
227    let mut i = 0;
228    while i < bytes.len() {
229        let b = bytes[i];
230        // 0xC0..=0xCF is the Figure A.1 non-spacing (combining-prefix) row.
231        if (0xC0..=0xCF).contains(&b) {
232            match combining_mark(b) {
233                Some(mark) if i + 1 < bytes.len() => {
234                    let base = bytes[i + 1];
235                    if let Some(c) = combine(b, base) {
236                        out.push(c);
237                    } else {
238                        // No precomposed form — emit base + Unicode combining
239                        // mark, which is canonically equivalent.
240                        out.push(iso_6937_single(base));
241                        out.push(mark);
242                    }
243                    i += 2;
244                }
245                // Undefined prefix (0xC0/0xC9/0xCC) or dangling prefix at end.
246                _ => {
247                    out.push('\u{FFFD}');
248                    i += 1;
249                }
250            }
251            continue;
252        }
253        out.push(iso_6937_single(b));
254        i += 1;
255    }
256    out
257}
258
259/// Decode a single (non-combining) byte of the default Latin table.
260///
261/// Source: ETSI EN 300 468 V1.19.1 (2025-02) Figure A.1 — "Character code
262/// table 00 - Latin alphabet with Unicode equivalents" (PDF p. 159). Per the
263/// note under the figure, the table is a superset of ISO/IEC 6937 with the
264/// Euro symbol (U+20AC) added at position 0xA4. Grey (undefined) positions
265/// decode to U+FFFD.
266fn iso_6937_single(b: u8) -> char {
267    match b {
268        0x00..=0x7F => b as char,
269        // Preserve ETSI Annex A.2 C1 control codes so the post-filter can act on them.
270        0x86 | 0x87 | 0x8A => b as char,
271        0x80..=0x9F => '\u{FFFD}',
272        0xA0 => '\u{00A0}', // NBSP
273        0xA1 => '¡',
274        0xA2 => '¢',
275        0xA3 => '£',
276        0xA4 => '\u{20AC}', // € — DVB addition (note under Figure A.1)
277        0xA5 => '¥',
278        0xA6 => '\u{FFFD}', // undefined
279        0xA7 => '§',
280        0xA8 => '\u{00A4}', // ¤ general currency sign
281        0xA9 => '\u{2018}', // ' left single quotation mark
282        0xAA => '\u{201C}', // " left double quotation mark
283        0xAB => '«',
284        0xAC => '\u{2190}', // ←
285        0xAD => '\u{2191}', // ↑
286        0xAE => '\u{2192}', // →
287        0xAF => '\u{2193}', // ↓
288        0xB0 => '°',
289        0xB1 => '±',
290        0xB2 => '²',
291        0xB3 => '³',
292        0xB4 => '\u{00D7}', // ×
293        0xB5 => 'µ',
294        0xB6 => '¶',
295        0xB7 => '·',
296        0xB8 => '\u{00F7}', // ÷
297        0xB9 => '\u{2019}', // ' right single quotation mark
298        0xBA => '\u{201D}', // " right double quotation mark
299        0xBB => '»',
300        0xBC => '¼',
301        0xBD => '½',
302        0xBE => '¾',
303        0xBF => '¿',
304        // Combining-prefix row; reached only for a dangling/undefined prefix.
305        0xC0..=0xCF => '\u{FFFD}',
306        0xD0 => '\u{2015}', // ― horizontal bar
307        0xD1 => '¹',
308        0xD2 => '®',
309        0xD3 => '©',
310        0xD4 => '\u{2122}', // ™
311        0xD5 => '\u{266A}', // ♪ eighth note
312        0xD6 => '¬',
313        0xD7 => '\u{00A6}',        // ¦ broken bar
314        0xD8..=0xDB => '\u{FFFD}', // undefined
315        0xDC => '\u{215B}',        // ⅛
316        0xDD => '\u{215C}',        // ⅜
317        0xDE => '\u{215D}',        // ⅝
318        0xDF => '\u{215E}',        // ⅞
319        0xE0 => '\u{2126}',        // Ω OHM SIGN
320        0xE1 => 'Æ',
321        0xE2 => '\u{0110}', // Đ
322        0xE3 => 'ª',
323        0xE4 => '\u{0126}', // Ħ
324        0xE5 => '\u{FFFD}', // undefined
325        0xE6 => '\u{0132}', // IJ
326        0xE7 => '\u{013F}', // Ŀ
327        0xE8 => '\u{0141}', // Ł
328        0xE9 => 'Ø',
329        0xEA => '\u{0152}', // Œ
330        0xEB => 'º',
331        0xEC => 'Þ',
332        0xED => '\u{0166}', // Ŧ
333        0xEE => '\u{014A}', // Ŋ
334        0xEF => '\u{0149}', // ʼn
335        0xF0 => '\u{0138}', // ĸ
336        0xF1 => 'æ',
337        0xF2 => '\u{0111}', // đ
338        0xF3 => 'ð',
339        0xF4 => '\u{0127}', // ħ
340        0xF5 => '\u{0131}', // ı dotless i
341        0xF6 => '\u{0133}', // ij
342        0xF7 => '\u{0140}', // ŀ
343        0xF8 => '\u{0142}', // ł
344        0xF9 => 'ø',
345        0xFA => '\u{0153}', // œ
346        0xFB => 'ß',
347        0xFC => '\u{00FE}', // þ
348        0xFD => '\u{0167}', // ŧ
349        0xFE => '\u{014B}', // ŋ
350        0xFF => '\u{00AD}', // SHY soft hyphen
351    }
352}
353
354/// Unicode combining mark for a Figure A.1 non-spacing prefix byte
355/// (row 0xC0..=0xCF). `None` for the undefined positions 0xC0/0xC9/0xCC.
356fn combining_mark(prefix: u8) -> Option<char> {
357    Some(match prefix {
358        0xC1 => '\u{0300}', // grave
359        0xC2 => '\u{0301}', // acute
360        0xC3 => '\u{0302}', // circumflex
361        0xC4 => '\u{0303}', // tilde
362        0xC5 => '\u{0304}', // macron
363        0xC6 => '\u{0306}', // breve
364        0xC7 => '\u{0307}', // dot above
365        0xC8 => '\u{0308}', // diaeresis
366        0xCA => '\u{030A}', // ring above
367        0xCB => '\u{0327}', // cedilla
368        0xCD => '\u{030B}', // double acute
369        0xCE => '\u{0328}', // ogonek
370        0xCF => '\u{030C}', // caron
371        _ => return None,
372    })
373}
374
375fn combine(prefix: u8, base: u8) -> Option<char> {
376    Some(match (prefix, base) {
377        (0xC1, b'A') => 'À',
378        (0xC1, b'E') => 'È',
379        (0xC1, b'I') => 'Ì',
380        (0xC1, b'O') => 'Ò',
381        (0xC1, b'U') => 'Ù',
382        (0xC1, b'a') => 'à',
383        (0xC1, b'e') => 'è',
384        (0xC1, b'i') => 'ì',
385        (0xC1, b'o') => 'ò',
386        (0xC1, b'u') => 'ù',
387        (0xC2, b'A') => 'Á',
388        (0xC2, b'E') => 'É',
389        (0xC2, b'I') => 'Í',
390        (0xC2, b'O') => 'Ó',
391        (0xC2, b'U') => 'Ú',
392        (0xC2, b'Y') => 'Ý',
393        (0xC2, b'a') => 'á',
394        (0xC2, b'e') => 'é',
395        (0xC2, b'i') => 'í',
396        (0xC2, b'o') => 'ó',
397        (0xC2, b'u') => 'ú',
398        (0xC2, b'y') => 'ý',
399        (0xC2, b'C') => 'Ć',
400        (0xC2, b'c') => 'ć',
401        (0xC2, b'L') => 'Ĺ',
402        (0xC2, b'l') => 'ĺ',
403        (0xC2, b'N') => 'Ń',
404        (0xC2, b'n') => 'ń',
405        (0xC2, b'R') => 'Ŕ',
406        (0xC2, b'r') => 'ŕ',
407        (0xC2, b'S') => 'Ś',
408        (0xC2, b's') => 'ś',
409        (0xC2, b'Z') => 'Ź',
410        (0xC2, b'z') => 'ź',
411        (0xC3, b'A') => 'Â',
412        (0xC3, b'E') => 'Ê',
413        (0xC3, b'I') => 'Î',
414        (0xC3, b'O') => 'Ô',
415        (0xC3, b'U') => 'Û',
416        (0xC3, b'a') => 'â',
417        (0xC3, b'e') => 'ê',
418        (0xC3, b'i') => 'î',
419        (0xC3, b'o') => 'ô',
420        (0xC3, b'u') => 'û',
421        (0xC4, b'A') => 'Ã',
422        (0xC4, b'N') => 'Ñ',
423        (0xC4, b'O') => 'Õ',
424        (0xC4, b'a') => 'ã',
425        (0xC4, b'n') => 'ñ',
426        (0xC4, b'o') => 'õ',
427        (0xC4, b'I') => 'Ĩ',
428        (0xC4, b'i') => 'ĩ',
429        (0xC4, b'U') => 'Ũ',
430        (0xC4, b'u') => 'ũ',
431        // macron
432        (0xC5, b'A') => 'Ā',
433        (0xC5, b'a') => 'ā',
434        (0xC5, b'E') => 'Ē',
435        (0xC5, b'e') => 'ē',
436        (0xC5, b'I') => 'Ī',
437        (0xC5, b'i') => 'ī',
438        (0xC5, b'O') => 'Ō',
439        (0xC5, b'o') => 'ō',
440        (0xC5, b'U') => 'Ū',
441        (0xC5, b'u') => 'ū',
442        // breve
443        (0xC6, b'A') => 'Ă',
444        (0xC6, b'a') => 'ă',
445        (0xC6, b'G') => 'Ğ',
446        (0xC6, b'g') => 'ğ',
447        (0xC6, b'U') => 'Ŭ',
448        (0xC6, b'u') => 'ŭ',
449        // dot above
450        (0xC7, b'C') => 'Ċ',
451        (0xC7, b'c') => 'ċ',
452        (0xC7, b'E') => 'Ė',
453        (0xC7, b'e') => 'ė',
454        (0xC7, b'G') => 'Ġ',
455        (0xC7, b'g') => 'ġ',
456        (0xC7, b'I') => 'İ',
457        (0xC7, b'Z') => 'Ż',
458        (0xC7, b'z') => 'ż',
459        (0xC8, b'A') => 'Ä',
460        (0xC8, b'E') => 'Ë',
461        (0xC8, b'I') => 'Ï',
462        (0xC8, b'O') => 'Ö',
463        (0xC8, b'U') => 'Ü',
464        (0xC8, b'Y') => 'Ÿ',
465        (0xC8, b'a') => 'ä',
466        (0xC8, b'e') => 'ë',
467        (0xC8, b'i') => 'ï',
468        (0xC8, b'o') => 'ö',
469        (0xC8, b'u') => 'ü',
470        (0xC8, b'y') => 'ÿ',
471        // ring above
472        (0xCA, b'A') => 'Å',
473        (0xCA, b'a') => 'å',
474        (0xCA, b'U') => 'Ů',
475        (0xCA, b'u') => 'ů',
476        (0xCB, b'C') => 'Ç',
477        (0xCB, b'c') => 'ç',
478        (0xCB, b'G') => 'Ģ',
479        (0xCB, b'g') => 'ģ',
480        (0xCB, b'K') => 'Ķ',
481        (0xCB, b'k') => 'ķ',
482        (0xCB, b'L') => 'Ļ',
483        (0xCB, b'l') => 'ļ',
484        (0xCB, b'N') => 'Ņ',
485        (0xCB, b'n') => 'ņ',
486        (0xCB, b'R') => 'Ŗ',
487        (0xCB, b'r') => 'ŗ',
488        (0xCB, b'S') => 'Ş',
489        (0xCB, b's') => 'ş',
490        (0xCB, b'T') => 'Ţ',
491        (0xCB, b't') => 'ţ',
492        // double acute
493        (0xCD, b'O') => 'Ő',
494        (0xCD, b'o') => 'ő',
495        (0xCD, b'U') => 'Ű',
496        (0xCD, b'u') => 'ű',
497        // ogonek
498        (0xCE, b'A') => 'Ą',
499        (0xCE, b'a') => 'ą',
500        (0xCE, b'E') => 'Ę',
501        (0xCE, b'e') => 'ę',
502        (0xCE, b'I') => 'Į',
503        (0xCE, b'i') => 'į',
504        (0xCE, b'U') => 'Ų',
505        (0xCE, b'u') => 'ų',
506        // caron
507        (0xCF, b'C') => 'Č',
508        (0xCF, b'c') => 'č',
509        (0xCF, b'D') => 'Ď',
510        (0xCF, b'd') => 'ď',
511        (0xCF, b'E') => 'Ě',
512        (0xCF, b'e') => 'ě',
513        (0xCF, b'L') => 'Ľ',
514        (0xCF, b'l') => 'ľ',
515        (0xCF, b'N') => 'Ň',
516        (0xCF, b'n') => 'ň',
517        (0xCF, b'R') => 'Ř',
518        (0xCF, b'r') => 'ř',
519        (0xCF, b'S') => 'Š',
520        (0xCF, b's') => 'š',
521        (0xCF, b'T') => 'Ť',
522        (0xCF, b't') => 'ť',
523        (0xCF, b'Z') => 'Ž',
524        (0xCF, b'z') => 'ž',
525        _ => return None,
526    })
527}
528
529fn decode_iso_8859(n: u8, bytes: &[u8]) -> String {
530    use encoding_rs::*;
531    let encoding: &'static Encoding = match n {
532        2 => ISO_8859_2,
533        3 => ISO_8859_3,
534        4 => ISO_8859_4,
535        5 => ISO_8859_5,
536        6 => ISO_8859_6,
537        7 => ISO_8859_7,
538        8 => ISO_8859_8,
539        9 => WINDOWS_1254,
540        10 => ISO_8859_10,
541        11 => WINDOWS_874,
542        13 => ISO_8859_13,
543        14 => ISO_8859_14,
544        15 => ISO_8859_15,
545        _ => return bytes.iter().map(|&b| b as char).collect(),
546    };
547    let (cow, _, _) = encoding.decode(bytes);
548    cow.into_owned()
549}
550
551fn decode_with(encoding: &'static encoding_rs::Encoding, bytes: &[u8]) -> String {
552    let (cow, _, _) = encoding.decode(bytes);
553    cow.into_owned()
554}
555
556fn decode_ucs2_be(bytes: &[u8]) -> String {
557    let code_units: Vec<u16> = bytes
558        .chunks_exact(2)
559        .map(|pair| u16::from_be_bytes([pair[0], pair[1]]))
560        .collect();
561    String::from_utf16_lossy(&code_units)
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn decode_empty_input_returns_empty_string() {
570        assert_eq!(decode_dvb_string(&[]), "");
571    }
572
573    #[test]
574    fn decode_plain_ascii_is_borrowed() {
575        let cow = decode(b"HELLO");
576        assert!(matches!(cow, Cow::Borrowed(_)));
577        assert_eq!(cow, "HELLO");
578    }
579
580    #[test]
581    fn decode_iso6937_latin_accent_chars() {
582        assert_eq!(decode_dvb_string(&[0x00, 0xC2, b'A']), "Á");
583        assert_eq!(decode_dvb_string(&[0x00, 0xC1, b'e']), "è");
584        assert_eq!(decode_dvb_string(&[0x00, 0xC8, b'o']), "ö");
585    }
586
587    #[test]
588    fn decode_selector_0x01_yields_iso8859_5_cyrillic() {
589        let s = decode_dvb_string(&[0x01, 0xB0, 0xB1]);
590        assert!(s.chars().all(|c| c != '\u{FFFD}'), "got: {s:?}");
591        assert!(!s.is_empty());
592    }
593
594    #[test]
595    fn decode_selector_0x10_extended_yields_iso8859_nn() {
596        let s = decode_dvb_string(&[0x10, 0x00, 0x09, b'A', b'B']);
597        assert_eq!(s, "AB");
598    }
599
600    #[test]
601    fn decode_selector_0x11_ucs2_be() {
602        let s = decode_dvb_string(&[0x11, 0x00, 0x41, 0x00, 0x42]);
603        assert_eq!(s, "AB");
604    }
605
606    #[test]
607    fn decode_selector_0x15_utf8_passthrough() {
608        let s = decode_dvb_string(&[0x15, 0xC3, 0xA9, 0xC3, 0xA9]);
609        assert_eq!(s, "éé");
610    }
611
612    #[test]
613    fn decode_control_chars_stripped_linefeed_becomes_space() {
614        let s = decode_dvb_string(b"A\x01B\nC");
615        assert_eq!(s, "AB C");
616    }
617
618    #[test]
619    fn emphasis_on_off_markers_stripped_per_annex_a2() {
620        // 0x86 and 0x87 are emphasis on/off markers per ETSI Annex A.2 — not
621        // representable in plain text, strip silently.
622        let s = decode_dvb_string(&[0x00, b'A', 0x86, b'B', 0x87, b'C']);
623        assert_eq!(s, "ABC");
624    }
625
626    #[test]
627    fn decode_annex_a2_crlf_0x8a_becomes_space() {
628        // 0x8A in DVB text maps to CR/LF per Annex A.2 — render as space.
629        let s = decode_dvb_string(&[0x00, b'A', 0x8A, b'B']);
630        assert_eq!(s, "A B");
631    }
632
633    #[test]
634    fn decode_selector_0x12_ksx1001_euc_kr() {
635        // EUC-KR 0xB0A1 = '가' (HANGUL SYLLABLE GA).
636        assert_eq!(decode_dvb_string(&[0x12, 0xB0, 0xA1]), "가");
637    }
638
639    #[test]
640    fn decode_selector_0x13_gb2312() {
641        // GB-2312/GBK 0xC4E3 = '你'.
642        assert_eq!(decode_dvb_string(&[0x13, 0xC4, 0xE3]), "你");
643    }
644
645    #[test]
646    fn decode_selector_0x14_big5() {
647        // Big5 0xA4A4 = '中'.
648        assert_eq!(decode_dvb_string(&[0x14, 0xA4, 0xA4]), "中");
649    }
650
651    /// A multi-byte trail byte in 0x80–0x9F must survive: the C1 control
652    /// filter operates on decoded code points, never on raw trail bytes.
653    /// GBK 0x8180 = '亐' (U+4E90, trail byte in the C1 range).
654    #[test]
655    fn decode_selector_0x13_gbk_trail_byte_in_c1_range() {
656        assert_eq!(decode_dvb_string(&[0x13, 0x81, 0x80]), "亐");
657    }
658
659    /// Annex A.1 two-byte control codes live at U+E080–U+E09F in the PUA
660    /// (Table A.2): U+E08A is CR/LF → space; the reserved rest is stripped.
661    /// GBK 0xABCD decodes to U+E08A; GBK 0xABC3 decodes to U+E080.
662    #[test]
663    fn two_byte_control_codes_filtered() {
664        assert_eq!(decode_dvb_string(&[0x13, 0xAB, 0xCD]), " ");
665        assert_eq!(decode_dvb_string(&[0x13, 0xAB, 0xC3]), "");
666    }
667
668    /// 0x1F consumes its 8-bit encoding_type_id; the body is undecodable
669    /// (no registered broadcast ids) and yields U+FFFD per byte.
670    #[test]
671    fn decode_selector_0x1f_encoding_type_id() {
672        let s = decode_dvb_string(&[0x1F, 0x01, 0x41, 0x42]);
673        assert_eq!(s.chars().count(), 2);
674        assert!(s.chars().all(|c| c == '\u{FFFD}'));
675    }
676
677    /// Table A.3 marks single-byte selector 0x08 reserved (no ISO 8859-12).
678    #[test]
679    fn reserved_selector_0x08_is_unsupported() {
680        let s = decode_dvb_string(&[0x08, 0x41, 0x42]);
681        assert!(s.chars().all(|c| c == '\u{FFFD}'));
682        assert_eq!(s.chars().count(), 2);
683    }
684
685    #[test]
686    fn unknown_selector_returns_replacement_characters() {
687        // Selector 0x16 is reserved for future use — each byte becomes U+FFFD.
688        let s = decode_dvb_string(&[0x16, 0xAA, 0xBB, 0xCC]);
689        assert_eq!(s.chars().count(), 3);
690        assert!(s.chars().all(|c| c == '\u{FFFD}'));
691    }
692
693    /// Pins the GR-area single-byte mappings to ETSI EN 300 468 V1.19.1
694    /// (2025-02) Figure A.1 — "Character code table 00 - Latin alphabet with
695    /// Unicode equivalents" (PDF p. 159; vendored at
696    /// `specs/etsi_en_300_468_v01.19.01_dvb_si.pdf`).
697    #[test]
698    fn figure_a1_gr_area_single_byte_mappings() {
699        let pins: &[(u8, char)] = &[
700            (0xA0, '\u{00A0}'), // NBSP
701            (0xA1, '¡'),
702            (0xA2, '¢'),
703            (0xA3, '£'),
704            (0xA4, '\u{20AC}'), // € — DVB addition (note under Figure A.1)
705            (0xA5, '¥'),
706            (0xA7, '§'),
707            (0xA8, '\u{00A4}'), // ¤ general currency sign
708            (0xA9, '\u{2018}'), // '
709            (0xAA, '\u{201C}'), // "
710            (0xAB, '«'),
711            (0xAC, '\u{2190}'), // ←
712            (0xAD, '\u{2191}'), // ↑
713            (0xAE, '\u{2192}'), // →
714            (0xAF, '\u{2193}'), // ↓
715            (0xB0, '°'),
716            (0xB1, '±'),
717            (0xB2, '²'),
718            (0xB3, '³'),
719            (0xB4, '\u{00D7}'), // ×
720            (0xB5, 'µ'),
721            (0xB6, '¶'),
722            (0xB7, '·'),
723            (0xB8, '\u{00F7}'), // ÷
724            (0xB9, '\u{2019}'), // '
725            (0xBA, '\u{201D}'), // "
726            (0xBB, '»'),
727            (0xBC, '¼'),
728            (0xBD, '½'),
729            (0xBE, '¾'),
730            (0xBF, '¿'),
731            (0xD0, '\u{2015}'), // ―
732            (0xD1, '¹'),
733            (0xD2, '®'),
734            (0xD3, '©'),
735            (0xD4, '\u{2122}'), // ™
736            (0xD5, '\u{266A}'), // ♪
737            (0xD6, '¬'),
738            (0xD7, '\u{00A6}'), // ¦
739            (0xDC, '\u{215B}'), // ⅛
740            (0xDD, '\u{215C}'), // ⅜
741            (0xDE, '\u{215D}'), // ⅝
742            (0xDF, '\u{215E}'), // ⅞
743            (0xE0, '\u{2126}'), // Ω OHM SIGN
744            (0xE1, 'Æ'),
745            (0xE2, '\u{0110}'), // Đ
746            (0xE3, 'ª'),
747            (0xE4, '\u{0126}'), // Ħ
748            (0xE6, '\u{0132}'), // IJ
749            (0xE7, '\u{013F}'), // Ŀ
750            (0xE8, '\u{0141}'), // Ł
751            (0xE9, 'Ø'),
752            (0xEA, '\u{0152}'), // Œ
753            (0xEB, 'º'),
754            (0xEC, 'Þ'),
755            (0xED, '\u{0166}'), // Ŧ
756            (0xEE, '\u{014A}'), // Ŋ
757            (0xEF, '\u{0149}'), // ʼn
758            (0xF0, '\u{0138}'), // ĸ
759            (0xF1, 'æ'),
760            (0xF2, '\u{0111}'), // đ
761            (0xF3, 'ð'),
762            (0xF4, '\u{0127}'), // ħ
763            (0xF5, '\u{0131}'), // ı
764            (0xF6, '\u{0133}'), // ij
765            (0xF7, '\u{0140}'), // ŀ
766            (0xF8, '\u{0142}'), // ł
767            (0xF9, 'ø'),
768            (0xFA, '\u{0153}'), // œ
769            (0xFB, 'ß'),
770            (0xFC, '\u{00FE}'), // þ
771            (0xFD, '\u{0167}'), // ŧ
772            (0xFE, '\u{014B}'), // ŋ
773            (0xFF, '\u{00AD}'), // SHY soft hyphen
774        ];
775        for &(byte, want) in pins {
776            let got = decode_dvb_string(&[0x00, byte]);
777            assert_eq!(
778                got,
779                want.to_string(),
780                "byte {byte:#04x}: want {want:?} (U+{:04X}), got {got:?}",
781                want as u32
782            );
783        }
784    }
785
786    /// Bytes undefined (grey) in Figure A.1 decode to U+FFFD.
787    #[test]
788    fn figure_a1_undefined_positions_are_replacement() {
789        for byte in [0xA6u8, 0xD8, 0xD9, 0xDA, 0xDB, 0xE5] {
790            let got = decode_dvb_string(&[0x00, byte]);
791            assert_eq!(got, "\u{FFFD}", "byte {byte:#04x} should be U+FFFD");
792        }
793    }
794
795    /// C-row prefixes with precomposed entries (Figure A.1 non-spacing row).
796    #[test]
797    fn figure_a1_combining_precomposed() {
798        assert_eq!(decode_dvb_string(&[0x00, 0xCA, b'a']), "å"); // ring U+030A
799        assert_eq!(decode_dvb_string(&[0x00, 0xCA, b'A']), "Å");
800        assert_eq!(decode_dvb_string(&[0x00, 0xCF, b's']), "š"); // caron U+030C
801        assert_eq!(decode_dvb_string(&[0x00, 0xCF, b'Z']), "Ž");
802        assert_eq!(decode_dvb_string(&[0x00, 0xCE, b'e']), "ę"); // ogonek U+0328
803        assert_eq!(decode_dvb_string(&[0x00, 0xCD, b'o']), "ő"); // double acute U+030B
804        assert_eq!(decode_dvb_string(&[0x00, 0xC7, b'z']), "ż"); // dot above U+0307
805        assert_eq!(decode_dvb_string(&[0x00, 0xC5, b'a']), "ā"); // macron U+0304
806        assert_eq!(decode_dvb_string(&[0x00, 0xC6, b'g']), "ğ"); // breve U+0306
807    }
808
809    /// A defined prefix with no precomposed form falls back to
810    /// base + Unicode combining mark (canonically equivalent).
811    #[test]
812    fn figure_a1_combining_fallback_emits_base_plus_mark() {
813        assert_eq!(decode_dvb_string(&[0x00, 0xC5, b'x']), "x\u{0304}");
814    }
815
816    /// Undefined C-row prefixes (0xC0, 0xC9, 0xCC) and a dangling prefix at
817    /// end of input decode to U+FFFD.
818    #[test]
819    fn figure_a1_combining_undefined_or_dangling_prefix() {
820        assert_eq!(decode_dvb_string(&[0x00, 0xC0, b'a']), "\u{FFFD}a");
821        assert_eq!(decode_dvb_string(&[0x00, 0xC9, b'a']), "\u{FFFD}a");
822        assert_eq!(decode_dvb_string(&[0x00, 0xCC, b'a']), "\u{FFFD}a");
823        assert_eq!(decode_dvb_string(&[0x00, 0xC2]), "\u{FFFD}");
824    }
825
826    #[test]
827    fn dvb_text_decodes_with_charset_selector() {
828        let t = DvbText::new(&[0x15, 0xC3, 0xA9]); // UTF-8 selector + é
829        assert_eq!(t.decode(), "é");
830        assert_eq!(t.raw(), &[0x15, 0xC3, 0xA9]);
831        assert_eq!(&t[..], &[0x15, 0xC3, 0xA9]); // Deref
832        assert_eq!(format!("{t}"), "é");
833    }
834
835    #[test]
836    fn lang_code_as_str() {
837        assert_eq!(LangCode(*b"fre").as_str(), "fre");
838        assert_eq!(LangCode([0xFF, b'r', b'e']).as_str(), "\u{FFFD}re"); // lossy, no panic
839    }
840
841    #[cfg(feature = "serde")]
842    #[test]
843    fn dvb_text_serializes_decoded() {
844        let t = DvbText::new(&[0x15, 0xC3, 0xA9]);
845        assert_eq!(serde_json::to_string(&t).unwrap(), "\"é\"");
846    }
847
848    #[cfg(feature = "serde")]
849    #[test]
850    fn lang_code_serializes_as_string() {
851        // Serialize-only: LangCode renders as its decoded string. Parsing FROM
852        // JSON is deliberately unsupported (re-parse from wire bytes instead).
853        let lc = LangCode(*b"FRA");
854        assert_eq!(serde_json::to_string(&lc).unwrap(), "\"FRA\"");
855    }
856}