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