Skip to main content

ebustl_parser/
subtitle.rs

1//! Grouping of TTI blocks into subtitles.
2//!
3//! A subtitle is conveyed by one or more TTI blocks sharing a Subtitle Number (SN). The Text
4//! Field continues across the extension blocks (EBN 00h-EFh) and ends in the last block
5//! (EBN FFh). User data blocks (EBN FEh) may be placed anywhere among the blocks of a subtitle
6//! and are not part of its text. Only the first block carries relevant values in bytes 4-15
7//! (CS, TCI, TCO, VP, JC and CF).
8
9use std::iter::FusedIterator;
10
11use crate::{TextElement, TtiBlock, text_field};
12
13/// A subtitle made up of one or more consecutive TTI blocks.
14///
15/// Created by [`Stl::subtitles`](crate::Stl::subtitles).
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct Subtitle<'a> {
18    blocks: &'a [TtiBlock],
19}
20
21impl<'a> Subtitle<'a> {
22    /// All TTI blocks of the subtitle, including user data blocks, in file order.
23    pub fn blocks(&self) -> &'a [TtiBlock] {
24        self.blocks
25    }
26
27    /// The Subtitle Number (SN) shared by all blocks of the subtitle.
28    ///
29    /// # Example
30    ///
31    /// ```rust
32    /// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
33    /// let subtitle = stl.subtitles().next().unwrap();
34    /// assert_eq!(stl.ttis[0].subtitle_number(), subtitle.subtitle_number());
35    /// ```
36    pub fn subtitle_number(&self) -> u16 {
37        self.blocks[0].sn
38    }
39
40    /// The block carrying the values valid for the whole subtitle: Subtitle Group Number,
41    /// Cumulative Status, Time Code In/Out, Vertical Position, Justification Code and
42    /// Comment Flag.
43    ///
44    /// This is the first text block, or the first block if the subtitle only has user data
45    /// or reserved blocks.
46    ///
47    /// # Example
48    ///
49    /// ```rust
50    /// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
51    /// let dfc = stl.gsi.disk_format_code();
52    /// for subtitle in stl.subtitles() {
53    ///     let header = subtitle.header_block();
54    ///     println!("{:?} --> {:?}", header.time_code_in(dfc), header.time_code_out(dfc));
55    /// }
56    /// ```
57    pub fn header_block(&self) -> &'a TtiBlock {
58        self.text_blocks().next().unwrap_or(&self.blocks[0])
59    }
60
61    /// Returns `true` if the subtitle contains comments not intended for transmission
62    /// (Comment Flag 01h in the header block).
63    ///
64    /// # Example
65    ///
66    /// ```rust
67    /// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
68    /// assert_eq!(0, stl.subtitles().filter(|subtitle| subtitle.is_comment()).count());
69    /// ```
70    pub fn is_comment(&self) -> bool {
71        self.header_block().is_comment()
72    }
73
74    /// The blocks whose Text Field make up the subtitle text, in file order.
75    pub fn text_blocks(&self) -> impl Iterator<Item = &'a TtiBlock> + use<'a> {
76        self.blocks.iter().filter(|block| block.is_text_block())
77    }
78
79    /// The user data blocks (EBN FEh) of the subtitle.
80    pub fn user_data_blocks(&self) -> impl Iterator<Item = &'a TtiBlock> + use<'a> {
81        self.blocks.iter().filter(|block| block.is_user_data())
82    }
83
84    /// Decodes the complete text of the subtitle.
85    ///
86    /// The Text Fields of all text blocks are concatenated before decoding, so text and
87    /// multi-byte characters may continue from one block to the next. User data and reserved
88    /// blocks are skipped. See [`TtiBlock::text`] for how control codes are handled.
89    ///
90    /// # Example
91    ///
92    /// ```rust
93    /// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
94    /// let subtitle = stl.subtitles().nth(11).unwrap();
95    /// assert_eq!("    dans la baie de New York.\r\n", subtitle.text());
96    /// ```
97    pub fn text(&self) -> String {
98        text_field::decode(&self.concatenated_text_field(), self.blocks[0].cct)
99    }
100
101    /// Decodes the complete subtitle into characters and control codes, e.g. teletext colors,
102    /// double height and boxing, or italics for open subtitles.
103    ///
104    /// The Text Fields are concatenated like for [`Subtitle::text`].
105    ///
106    /// # Example
107    ///
108    /// ```rust
109    /// use ebustl_parser::TextElement;
110    ///
111    /// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
112    /// let elements = stl.subtitles().nth(2).unwrap().text_elements();
113    /// let text: Vec<&str> = elements
114    ///     .iter()
115    ///     .filter_map(|element| match element {
116    ///         TextElement::Text(text) => Some(text.as_str()),
117    ///         _ => None,
118    ///     })
119    ///     .collect();
120    /// assert_eq!(vec!["au pied de la statue de la Liberté."], text);
121    /// ```
122    pub fn text_elements(&self) -> Vec<TextElement> {
123        text_field::decode_elements(&self.concatenated_text_field(), self.blocks[0].cct)
124    }
125
126    /// The content of the Text Fields of all text blocks, in order.
127    fn concatenated_text_field(&self) -> Vec<u8> {
128        self.text_blocks()
129            .flat_map(|block| block.text_field_content())
130            .copied()
131            .collect()
132    }
133}
134
135/// Iterator over the subtitles of an [`Stl`](crate::Stl), see
136/// [`Stl::subtitles`](crate::Stl::subtitles).
137#[derive(Debug, Clone)]
138pub struct Subtitles<'a> {
139    remaining: &'a [TtiBlock],
140}
141
142impl<'a> Subtitles<'a> {
143    pub(crate) fn new(blocks: &'a [TtiBlock]) -> Self {
144        Self { remaining: blocks }
145    }
146}
147
148impl<'a> Iterator for Subtitles<'a> {
149    type Item = Subtitle<'a>;
150
151    fn next(&mut self) -> Option<Self::Item> {
152        if self.remaining.is_empty() {
153            return None;
154        }
155        let (blocks, remaining) = self.remaining.split_at(subtitle_len(self.remaining));
156        self.remaining = remaining;
157        Some(Subtitle { blocks })
158    }
159}
160
161impl FusedIterator for Subtitles<'_> {}
162
163/// Number of blocks, from the start of `blocks`, belonging to the first subtitle.
164///
165/// The subtitle continues while the Subtitle Number stays the same, but once its last block
166/// (EBN FFh) is seen only non-text blocks (user data or reserved) may follow. Another text
167/// block with the same Subtitle Number starts a new subtitle.
168fn subtitle_len(blocks: &[TtiBlock]) -> usize {
169    let Some(first) = blocks.first() else {
170        return 0;
171    };
172    let mut ended = first.is_last_block();
173    let mut len = 1;
174    for block in &blocks[1..] {
175        if block.sn != first.sn || (ended && block.is_text_block()) {
176            break;
177        }
178        ended |= block.is_last_block();
179        len += 1;
180    }
181    len
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::{CharacterCodeTable, CumulativeStatus, Stl, Time};
188
189    const TF_LENGTH: usize = 112;
190
191    fn time(seconds: u8) -> Time {
192        Time {
193            hours: 0,
194            minutes: 0,
195            seconds,
196            frames: 0,
197        }
198    }
199
200    /// A block with the given text bytes, padded with unused space (8Fh) to 112 bytes
201    fn block(sn: u16, ebn: u8, text: &[u8]) -> TtiBlock {
202        assert!(text.len() <= TF_LENGTH);
203        let mut tf = text.to_vec();
204        tf.resize(TF_LENGTH, 0x8F);
205        TtiBlock {
206            sgn: 0,
207            sn,
208            ebn,
209            cs: CumulativeStatus::NotPartOfASet,
210            tci: time(1),
211            tco: time(2),
212            vp: 20,
213            jc: 2,
214            cf: 0,
215            tf,
216            cct: CharacterCodeTable::Latin,
217        }
218    }
219
220    fn stl(ttis: Vec<TtiBlock>) -> Stl {
221        Stl {
222            gsi: crate::GsiBlock::new(),
223            ttis,
224        }
225    }
226
227    fn block_counts(stl: &Stl) -> Vec<usize> {
228        stl.subtitles().map(|s| s.blocks().len()).collect()
229    }
230
231    #[test]
232    fn no_blocks_gives_no_subtitles() {
233        assert_eq!(0, stl(vec![]).subtitles().count());
234    }
235
236    #[test]
237    fn single_block_subtitles() {
238        let stl = stl(vec![
239            block(1, 0xFF, b"one"),
240            block(2, 0xFF, b"two"),
241            block(3, 0xFF, b"three"),
242        ]);
243
244        let texts: Vec<String> = stl.subtitles().map(|s| s.text()).collect();
245        assert_eq!(vec!["one", "two", "three"], texts);
246        let numbers: Vec<u16> = stl.subtitles().map(|s| s.subtitle_number()).collect();
247        assert_eq!(vec![1, 2, 3], numbers);
248    }
249
250    #[test]
251    fn extension_blocks_are_merged() {
252        // A full first block without terminating control code, the word continues in the
253        // next block
254        let mut first = b"Line one".to_vec();
255        first.push(0x8A);
256        first.resize(TF_LENGTH - 3, b'x');
257        first.extend(b"Bee");
258        let stl = stl(vec![
259            block(1, 0x00, &first),
260            block(1, 0x01, b"kem"),
261            block(1, 0xFF, b"olen."),
262            block(2, 0xFF, b"next"),
263        ]);
264
265        assert_eq!(vec![3, 1], block_counts(&stl));
266        let text = stl.subtitles().next().unwrap().text();
267        assert!(text.starts_with("Line one\r\nxxx"), "{text:?}");
268        assert!(text.ends_with("xxxBeekemolen."), "{text:?}");
269    }
270
271    #[test]
272    fn character_split_across_blocks_is_decoded() {
273        // ISO 6937 floating accent: diaeresis (C8h) followed by 'a' is 'ä'
274        let stl = stl(vec![block(1, 0x00, b"B\xC8"), block(1, 0xFF, b"ar")]);
275
276        assert_eq!("Bär", stl.subtitles().next().unwrap().text());
277    }
278
279    #[test]
280    fn user_data_is_not_part_of_the_text() {
281        let stl = stl(vec![
282            block(1, 0xFE, b"user data first"),
283            block(1, 0x00, b"Hello "),
284            block(1, 0xFE, b"user data between"),
285            block(1, 0xFF, b"world"),
286            block(1, 0xFE, b"user data last"),
287            block(2, 0xFF, b"next"),
288        ]);
289
290        assert_eq!(vec![5, 1], block_counts(&stl));
291        let subtitle = stl.subtitles().next().unwrap();
292        assert_eq!("Hello world", subtitle.text());
293        let user_data: Vec<String> = subtitle
294            .user_data_blocks()
295            .map(|block| block.text())
296            .collect();
297        assert_eq!(
298            vec!["user data first", "user data between", "user data last"],
299            user_data
300        );
301        assert_eq!(2, subtitle.text_blocks().count());
302    }
303
304    #[test]
305    fn reserved_blocks_are_not_part_of_the_text() {
306        let stl = stl(vec![
307            block(1, 0x00, b"Hello "),
308            block(1, 0xF0, b"reserved"),
309            block(1, 0xFD, b"reserved"),
310            block(1, 0xFF, b"world"),
311        ]);
312
313        let subtitle = stl.subtitles().next().unwrap();
314        assert_eq!(4, subtitle.blocks().len());
315        assert_eq!("Hello world", subtitle.text());
316    }
317
318    #[test]
319    fn header_block_is_first_text_block() {
320        let mut text_block = block(1, 0xFF, b"text");
321        text_block.tci = time(10);
322        let stl = stl(vec![block(1, 0xFE, b"user data"), text_block]);
323
324        let subtitle = stl.subtitles().next().unwrap();
325        assert_eq!(time(10), subtitle.header_block().tci);
326    }
327
328    #[test]
329    fn header_block_without_text_blocks_is_first_block() {
330        let stl = stl(vec![block(1, 0xFE, b"user data")]);
331
332        let subtitle = stl.subtitles().next().unwrap();
333        assert!(subtitle.header_block().is_user_data());
334        assert_eq!("", subtitle.text());
335    }
336
337    #[test]
338    fn same_subtitle_number_after_last_block_starts_new_subtitle() {
339        let stl = stl(vec![
340            block(0, 0xFF, b"one"),
341            block(0, 0xFF, b"two"),
342            block(0, 0x00, b"thr"),
343            block(0, 0xFF, b"ee"),
344        ]);
345
346        let texts: Vec<String> = stl.subtitles().map(|s| s.text()).collect();
347        assert_eq!(vec!["one", "two", "three"], texts);
348    }
349
350    #[test]
351    fn subtitle_without_last_block_ends_at_next_subtitle_number() {
352        let stl = stl(vec![block(1, 0x00, b"one"), block(2, 0xFF, b"two")]);
353
354        assert_eq!(vec![1, 1], block_counts(&stl));
355    }
356
357    #[test]
358    fn comment_flag() {
359        let mut comment = block(1, 0x00, b"comment ");
360        comment.cf = 1;
361        // Only the header block's Comment Flag is relevant
362        let stl = stl(vec![
363            comment,
364            block(1, 0xFF, b"text"),
365            block(2, 0xFF, b"sub"),
366        ]);
367
368        let comments: Vec<bool> = stl.subtitles().map(|s| s.is_comment()).collect();
369        assert_eq!(vec![true, false], comments);
370    }
371
372    #[test]
373    fn block_text_filling_the_whole_field_is_decoded() {
374        let text = [b'a'; TF_LENGTH];
375        assert_eq!("a".repeat(TF_LENGTH), block(1, 0xFF, &text).text());
376    }
377
378    #[test]
379    fn block_text_stops_at_unused_space() {
380        let tti = block(1, 0xFF, b"one\x8Atwo\x8Fignored");
381        assert_eq!("one\r\ntwo", tti.text());
382    }
383
384    #[test]
385    fn parsed_file_has_one_subtitle_per_block() {
386        let stl = crate::parse_stl_from_file("stls/test.stl").expect("Parse stls/test.stl");
387
388        assert_eq!(stl.ttis.len(), stl.subtitles().count());
389        for (subtitle, tti) in stl.subtitles().zip(&stl.ttis) {
390            assert_eq!(tti.text(), subtitle.text());
391        }
392    }
393
394    #[test]
395    fn text_elements_continue_across_blocks() {
396        use crate::OpenControl;
397
398        let stl = stl(vec![block(1, 0x00, b"\x80Hel"), block(1, 0xFF, b"lo\x81")]);
399
400        assert_eq!(
401            vec![
402                TextElement::Open(OpenControl::ItalicsOn),
403                TextElement::Text("Hello".to_string()),
404                TextElement::Open(OpenControl::ItalicsOff),
405            ],
406            stl.subtitles().next().unwrap().text_elements()
407        );
408    }
409}