Skip to main content

ebustl_parser/
lib.rs

1//! Parser and writer for subtitle files in the EBU STL format, specified in
2//! [EBU Tech 3264](https://tech.ebu.ch/docs/tech/tech3264.pdf).
3//!
4//! A file consists of a [`GsiBlock`] with general information, followed by [`TtiBlock`]s with
5//! the text and timing of the subtitles. A subtitle may span several TTI blocks, use
6//! [`Stl::subtitles`] to get the complete subtitles.
7//!
8//! # Example
9//!
10//! ```rust
11//! use ebustl_parser::parse_stl_from_file;
12//!
13//! let stl = parse_stl_from_file("stls/test.stl").expect("Parse stl from file");
14//! let dfc = stl.gsi.disk_format_code();
15//! for subtitle in stl.subtitles() {
16//!     let header = subtitle.header_block();
17//!     println!(
18//!         "{:?} --> {:?}: {}",
19//!         header.time_code_in(dfc),
20//!         header.time_code_out(dfc),
21//!         subtitle.text()
22//!     );
23//! }
24//! ```
25#![warn(missing_docs)]
26
27use std::fs::File;
28use std::io::prelude::*;
29use std::{fmt, path::Path};
30
31mod fields;
32mod gsi;
33mod latin;
34/// Parsing of STL data in memory.
35pub mod parser;
36mod subtitle;
37mod text_field;
38mod time;
39mod tti;
40mod validate;
41
42pub use crate::fields::{Date, InvalidFieldValue, Justification, LanguageCode};
43use crate::gsi::MAX_TNB;
44pub use crate::gsi::{
45    CharacterCodeTable, CodePageNumber, DiskFormatCode, DisplayStandardCode, GsiBlock,
46    TimeCodeStatus,
47};
48pub use crate::parser::ParseError;
49pub use crate::subtitle::{Subtitle, Subtitles};
50pub use crate::text_field::{Color, OpenControl, TeletextControl, TextElement};
51pub use crate::time::Time;
52pub use crate::tti::{CumulativeStatus, TtiBlock, TtiFormat};
53use crate::tti::{EBN_LAST_BLOCK, MAX_TEXT_BLOCKS};
54pub use crate::validate::ValidationIssue;
55
56use crate::parser::parse_stl_from_slice;
57use crate::text_field::Presentation;
58
59/// A subtitle list in the EBU STL format: a GSI block followed by TTI blocks.
60#[derive(Debug, PartialEq, Eq)]
61pub struct Stl {
62    /// The General Subtitle Information (GSI) block
63    pub gsi: GsiBlock,
64    /// The Text and Timing Information (TTI) blocks, in file order
65    pub ttis: Vec<TtiBlock>,
66}
67
68impl fmt::Display for Stl {
69    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
70        write!(f, "{}\n{:?}\n", self.gsi, self.ttis)
71    }
72}
73
74impl Stl {
75    /// Creates an empty subtitle list with a default GSI block, see [`GsiBlock::new`].
76    pub fn new() -> Stl {
77        Stl {
78            gsi: GsiBlock::new(),
79            ttis: vec![],
80        }
81    }
82
83    /// Writes the subtitle list to a file.
84    ///
85    /// # Errors
86    ///
87    /// Fails if the file cannot be written, or if a text field in the GSI block cannot be encoded
88    /// with its code page.
89    ///
90    /// # Example
91    ///
92    /// ```rust
93    /// use ebustl_parser::{Stl, Time, TtiFormat, parse_stl_from_file};
94    ///
95    /// let time = |seconds| Time { hours: 10, minutes: 0, seconds, frames: 0 };
96    /// let mut stl = Stl::new();
97    /// stl.add_sub(time(1), time(3), "Hello", TtiFormat { jc: 2, vp: 20, dh: false })
98    ///     .expect("Add subtitle");
99    ///
100    /// let path = std::env::temp_dir().join("ebustl_parser_write_to_file_example.stl");
101    /// stl.write_to_file(&path).expect("Write file");
102    /// let written = parse_stl_from_file(&path).expect("Parse file");
103    /// assert_eq!("Hello", written.subtitles().next().unwrap().text());
104    /// # std::fs::remove_file(&path).ok();
105    /// ```
106    pub fn write_to_file<P: AsRef<Path>>(&self, filename: P) -> Result<(), ParseError> {
107        let mut f = File::create(filename)?;
108        f.write_all(&self.gsi.serialize()?)?;
109        for tti in self.ttis.iter() {
110            f.write_all(&tti.serialize())?;
111        }
112        Ok(())
113    }
114
115    /// Adds a subtitle after the existing TTI blocks and updates TNB and TNS.
116    ///
117    /// Rows are separated by `\n` or `\r\n`. Text that does not fit in one TTI block continues
118    /// in extension blocks. Teletext control codes boxing each row, and double height if
119    /// `opt.dh` is set, are added unless the Display Standard Code is open subtitling.
120    ///
121    /// The Subtitle Number continues from the last block, starting at 1. The first subtitle sets
122    /// the Time Code: First In-Cue (TCF) of the GSI block.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error, leaving the `Stl` unchanged, if the text needs more than 241 TTI
127    /// blocks, if TNB would exceed 99 999 or if the Subtitle Number would exceed FFFFh.
128    ///
129    /// # Example
130    ///
131    /// ```rust
132    /// use ebustl_parser::{Stl, Time, TtiFormat};
133    ///
134    /// let time = |seconds| Time { hours: 10, minutes: 0, seconds, frames: 0 };
135    /// let format = TtiFormat { jc: 2, vp: 20, dh: false };
136    ///
137    /// let mut stl = Stl::new();
138    /// stl.add_sub(time(1), time(3), "First row\nSecond row", format)
139    ///     .expect("Add subtitle");
140    ///
141    /// let subtitle = stl.subtitles().next().unwrap();
142    /// assert_eq!("First row\r\nSecond row", subtitle.text());
143    /// assert_eq!(1, stl.gsi.total_number_of_subtitles());
144    /// ```
145    pub fn add_sub(
146        &mut self,
147        tci: Time,
148        tco: Time,
149        txt: &str,
150        opt: TtiFormat,
151    ) -> Result<(), ParseError> {
152        let presentation = match self.gsi.dsc {
153            DisplayStandardCode::OpenSubtitling => Presentation::Open,
154            _ => Presentation::Teletext {
155                double_height: opt.dh,
156            },
157        };
158        let text_fields = text_field::encode_text_fields(txt, presentation, self.gsi.cct);
159        if text_fields.len() > MAX_TEXT_BLOCKS {
160            return Err(ParseError::SubtitleTextTooLong {
161                blocks: text_fields.len(),
162            });
163        }
164        let tnb = u32::try_from(text_fields.len())
165            .ok()
166            .and_then(|blocks| self.gsi.tnb.checked_add(blocks))
167            .filter(|&tnb| tnb <= MAX_TNB)
168            .ok_or(ParseError::TooManyTtiBlocks)?;
169        let sn = match self.ttis.last() {
170            Some(last) => last
171                .sn
172                .checked_add(1)
173                .ok_or(ParseError::SubtitleNumberOverflow)?,
174            None => 1,
175        };
176        if self.ttis.is_empty() {
177            // TCF is the Time Code In of the first subtitle
178            self.gsi.tcf = format!(
179                "{:02}{:02}{:02}{:02}",
180                tci.hours, tci.minutes, tci.seconds, tci.frames
181            );
182        }
183
184        let last_block = text_fields.len() - 1;
185        for (i, (tf, ebn)) in text_fields.into_iter().zip(0_u8..).enumerate() {
186            self.ttis.push(TtiBlock {
187                sgn: 0,
188                sn,
189                ebn: if i == last_block { EBN_LAST_BLOCK } else { ebn },
190                cs: CumulativeStatus::NotPartOfASet,
191                tci,
192                tco,
193                vp: opt.vp,
194                jc: opt.jc,
195                cf: 0,
196                tf,
197                cct: self.gsi.cct,
198            });
199        }
200        self.gsi.tnb = tnb;
201        self.gsi.tns += 1;
202        Ok(())
203    }
204
205    /// Iterates over the subtitles, each made up of one or more TTI blocks.
206    ///
207    /// Consecutive blocks with the same Subtitle Number form one subtitle. A subtitle ends at
208    /// its last block (EBN FFh), although a following user data block (EBN FEh) with the same
209    /// Subtitle Number still belongs to it.
210    ///
211    /// # Example
212    ///
213    /// ```rust
214    /// use ebustl_parser::parse_stl_from_file;
215    ///
216    /// let stl = parse_stl_from_file("stls/test.stl").expect("Parse stl from file");
217    /// let texts: Vec<String> = stl
218    ///     .subtitles()
219    ///     .filter(|subtitle| !subtitle.is_comment())
220    ///     .map(|subtitle| subtitle.text())
221    ///     .collect();
222    /// assert_eq!("    dans la baie de New York.\r\n", texts[11]);
223    /// ```
224    pub fn subtitles(&self) -> Subtitles<'_> {
225        Subtitles::new(&self.ttis)
226    }
227}
228
229impl Default for Stl {
230    fn default() -> Self {
231        Self::new()
232    }
233}
234
235/// Reads and parses an STL file.
236///
237/// Use [`parser::parse_stl_from_slice`] to parse data in memory.
238///
239/// # Errors
240///
241/// Fails if the file cannot be read or is not a valid STL file.
242///
243/// # Example
244///
245/// ```rust
246/// use ebustl_parser::parse_stl_from_file;
247///
248/// let stl = parse_stl_from_file("stls/test.stl").expect("Parse stl from file");
249/// assert_eq!(13, stl.ttis.len());
250/// ```
251pub fn parse_stl_from_file<P: AsRef<Path>>(filename: P) -> Result<Stl, ParseError> {
252    let mut f = File::open(filename)?;
253    let mut buffer = vec![];
254    f.read_to_end(&mut buffer)?;
255
256    Ok(parse_stl_from_slice(&mut buffer.as_slice())?)
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn time(seconds: u8) -> Time {
264        Time {
265            hours: 10,
266            minutes: 0,
267            seconds,
268            frames: 0,
269        }
270    }
271
272    fn format(dh: bool) -> TtiFormat {
273        TtiFormat { jc: 2, vp: 20, dh }
274    }
275
276    fn add(stl: &mut Stl, text: &str) -> Result<(), ParseError> {
277        stl.add_sub(time(1), time(2), text, format(false))
278    }
279
280    fn ebns(stl: &Stl) -> Vec<u8> {
281        stl.ttis.iter().map(|tti| tti.ebn).collect()
282    }
283
284    #[test]
285    fn add_sub_with_rows() {
286        let mut stl = Stl::new();
287        add(&mut stl, "One\nTwo").unwrap();
288        stl.add_sub(time(3), time(4), "Three\nFour", format(true))
289            .unwrap();
290
291        let texts: Vec<String> = stl.subtitles().map(|s| s.text()).collect();
292        assert_eq!(vec!["One\r\nTwo", "Three\r\n\r\nFour"], texts);
293        assert_eq!(vec![0xFF, 0xFF], ebns(&stl));
294    }
295
296    #[test]
297    fn add_sub_numbers_subtitles_from_one() {
298        let mut stl = Stl::new();
299        add(&mut stl, "One").unwrap();
300        add(&mut stl, "Two").unwrap();
301
302        let numbers: Vec<u16> = stl.ttis.iter().map(|tti| tti.sn).collect();
303        assert_eq!(vec![1, 2], numbers);
304    }
305
306    #[test]
307    fn add_sub_continues_subtitle_number_of_last_block() {
308        let mut stl = parse_stl_from_file("stls/test.stl").unwrap();
309        let last_sn = stl.ttis.last().unwrap().sn;
310
311        add(&mut stl, "Added").unwrap();
312        assert_eq!(last_sn + 1, stl.ttis.last().unwrap().sn);
313    }
314
315    #[test]
316    fn add_sub_long_text_uses_extension_blocks() {
317        let text = "A long subtitle text, ".repeat(12);
318        let mut stl = Stl::new();
319        add(&mut stl, &text).unwrap();
320        add(&mut stl, "Next").unwrap();
321
322        // 264 characters and 4 teletext control codes need 3 blocks
323        assert_eq!(vec![0x00, 0x01, 0xFF, 0xFF], ebns(&stl));
324        assert_eq!(
325            vec![1, 1, 1, 2],
326            stl.ttis.iter().map(|t| t.sn).collect::<Vec<_>>()
327        );
328        assert_eq!(4, stl.gsi.tnb);
329        assert_eq!(2, stl.gsi.tns);
330        let subtitle = stl.subtitles().next().unwrap();
331        assert_eq!(text.trim_end(), subtitle.text().trim_end());
332        assert!(stl.ttis.iter().all(|tti| tti.tci == time(1)));
333    }
334
335    #[test]
336    fn add_sub_open_subtitles_have_no_teletext_codes() {
337        let mut stl = Stl::new();
338        stl.gsi.dsc = DisplayStandardCode::OpenSubtitling;
339        stl.add_sub(time(1), time(2), "One\nTwo", format(true))
340            .unwrap();
341
342        assert_eq!(b"One\x8ATwo\x8F", &stl.ttis[0].tf[..8]);
343    }
344
345    #[test]
346    fn add_sub_too_long_text_is_an_error() {
347        let mut stl = Stl::new();
348        let text = "a".repeat(MAX_TEXT_BLOCKS * text_field::TF_LENGTH);
349
350        let err = add(&mut stl, &text).unwrap_err();
351        assert!(
352            matches!(err, ParseError::SubtitleTextTooLong { blocks: 242 }),
353            "{err:?}"
354        );
355        assert!(stl.ttis.is_empty());
356        assert_eq!(0, stl.gsi.tnb);
357        assert_eq!(0, stl.gsi.tns);
358    }
359
360    #[test]
361    fn add_sub_longest_allowed_text() {
362        let mut stl = Stl::new();
363        // The last block needs room for the terminating unused space
364        let text = "a".repeat(MAX_TEXT_BLOCKS * text_field::TF_LENGTH - 5);
365
366        add(&mut stl, &text).unwrap();
367        assert_eq!(MAX_TEXT_BLOCKS, stl.ttis.len());
368        assert_eq!(0xEF, stl.ttis[MAX_TEXT_BLOCKS - 2].ebn);
369        assert_eq!(0xFF, stl.ttis[MAX_TEXT_BLOCKS - 1].ebn);
370    }
371
372    #[test]
373    fn add_sub_tnb_overflow_is_an_error() {
374        let mut stl = Stl::new();
375        stl.gsi.tnb = MAX_TNB;
376
377        assert!(matches!(
378            add(&mut stl, "One"),
379            Err(ParseError::TooManyTtiBlocks)
380        ));
381        assert!(stl.ttis.is_empty());
382    }
383
384    #[test]
385    fn add_sub_subtitle_number_overflow_is_an_error() {
386        let mut stl = Stl::new();
387        stl.ttis.push(TtiBlock::new(
388            u16::MAX,
389            time(1),
390            time(2),
391            "Last",
392            format(false),
393            CharacterCodeTable::Latin,
394        ));
395
396        assert!(matches!(
397            add(&mut stl, "One"),
398            Err(ParseError::SubtitleNumberOverflow)
399        ));
400        assert_eq!(1, stl.ttis.len());
401    }
402
403    #[test]
404    fn written_subtitles_are_parsed_back() {
405        let mut stl = Stl::new();
406        add(&mut stl, "Première ligne\nDeuxième ligne à $5").unwrap();
407        add(&mut stl, &"Long text ".repeat(20)).unwrap();
408
409        let path = std::env::temp_dir().join("ebustl_parser_add_sub.stl");
410        stl.write_to_file(&path).unwrap();
411        let parsed = parse_stl_from_file(&path);
412        std::fs::remove_file(&path).ok();
413        let parsed = parsed.unwrap();
414
415        assert_eq!(stl.ttis, parsed.ttis);
416        assert_eq!(stl.gsi.tnb, parsed.gsi.tnb);
417        assert_eq!(stl.gsi.tns, parsed.gsi.tns);
418        let texts: Vec<String> = parsed.subtitles().map(|s| s.text()).collect();
419        assert_eq!("Première ligne\r\nDeuxième ligne à $5", texts[0]);
420        assert_eq!("Long text ".repeat(20).trim_end(), texts[1].trim_end());
421    }
422
423    #[test]
424    fn tti_block_new_truncates_to_one_block() {
425        let tti = TtiBlock::new(
426            1,
427            time(1),
428            time(2),
429            &"a".repeat(200),
430            format(false),
431            CharacterCodeTable::Latin,
432        );
433
434        assert_eq!(text_field::TF_LENGTH, tti.tf.len());
435        assert_eq!(
436            text_field::TF_UNUSED_SPACE,
437            tti.tf[text_field::TF_LENGTH - 1]
438        );
439        assert_eq!("a".repeat(text_field::TF_LENGTH - 3), tti.text());
440    }
441
442    #[test]
443    fn renamed_getters() {
444        let stl = parse_stl_from_file("stls/test.stl").unwrap();
445        // TNG is 1 and MNC is 40 in the test file
446        assert_eq!(1, stl.gsi.total_number_of_subtitle_groups());
447        assert_eq!(40, stl.gsi.max_number_of_chars_in_row());
448
449        let tti = &stl.ttis[1];
450        assert_eq!(tti.sn, tti.subtitle_number());
451    }
452}