ebustl-parser 0.7.0

Parse subtitle files in the EBU-STL format with Rust.
Documentation
//! Text and Timing Information (TTI) block, Tech 3264 section 4.3.

use std::fmt;

use crate::text_field::{self, Presentation};
use crate::{
    CharacterCodeTable, DiskFormatCode, InvalidFieldValue, Justification, ParseError, TextElement,
    Time, fields,
};

/// Presentation of a subtitle created with [`Stl::add_sub`](crate::Stl::add_sub) or
/// [`TtiBlock::new`].
pub struct TtiFormat {
    #[doc = "Justification Code (JC), 00h-03h"]
    pub jc: u8,
    #[doc = "Vertical Position (VP), the row of the first subtitle row"]
    pub vp: u8,
    #[doc = "Double height, only used for teletext subtitles"]
    pub dh: bool,
}

/// Cumulative Status (CS), whether a subtitle is part of a cumulative set of subtitles.
#[derive(Debug, PartialEq, Eq)]
pub enum CumulativeStatus {
    /// 00h, the subtitle is not part of a cumulative set
    NotPartOfASet,
    /// 01h, the first subtitle of a cumulative set
    FirstInSet,
    /// 02h, an intermediate subtitle of a cumulative set
    IntermediateInSet,
    /// 03h, the last subtitle of a cumulative set
    LastInSet,
}

impl CumulativeStatus {
    pub(crate) fn parse(d: u8) -> Result<CumulativeStatus, ParseError> {
        match d {
            0 => Ok(CumulativeStatus::NotPartOfASet),
            1 => Ok(CumulativeStatus::FirstInSet),
            2 => Ok(CumulativeStatus::IntermediateInSet),
            3 => Ok(CumulativeStatus::LastInSet),
            _ => Err(ParseError::CumulativeStatus),
        }
    }

    pub(crate) fn serialize(&self) -> u8 {
        match *self {
            CumulativeStatus::NotPartOfASet => 0,
            CumulativeStatus::FirstInSet => 1,
            CumulativeStatus::IntermediateInSet => 2,
            CumulativeStatus::LastInSet => 3,
        }
    }
}

/// Text and Timing Information (TTI) block, 128 bytes with the text and timing of a subtitle.
#[derive(PartialEq, Eq)]
pub struct TtiBlock {
    #[doc = "0 Subtitle Group Number. 00h-FFh"]
    pub(crate) sgn: u8,
    #[doc = "1..2 Subtitle Number. 0000h-FFFFh"]
    pub(crate) sn: u16,
    #[doc = "3 Extension Block Number. 00h-FFh"]
    pub(crate) ebn: u8,
    #[doc = "4 Cumulative Status. 00-03h"]
    pub(crate) cs: CumulativeStatus,
    #[doc = "5..8 Time Code In"]
    pub(crate) tci: Time,
    #[doc = "9..12 Time Code Out"]
    pub(crate) tco: Time,
    #[doc = "13 Vertical Position"]
    pub(crate) vp: u8,
    #[doc = "14 Justification Code"]
    pub(crate) jc: u8,
    #[doc = "15 Comment Flag"]
    pub(crate) cf: u8,
    #[doc = "16..127 Text Field, encoded with the Character Code Table"]
    pub(crate) tf: Vec<u8>,
    #[doc = "Copy of the Character Code Table of the GSI block, to decode the Text Field"]
    pub(crate) cct: CharacterCodeTable,
}

// Accessors in the order of the fields in the block. Accessors that validate a field return
// the value as stored in the file in the error, see `InvalidFieldValue`.
impl TtiBlock {
    /// Subtitle Group Number (SGN).
    pub fn subtitle_group_number(&self) -> u8 {
        self.sgn
    }

    /// Subtitle Number (SN).
    pub fn subtitle_number(&self) -> u16 {
        self.sn
    }

    /// Extension Block Number (EBN), see also [`TtiBlock::is_last_block`] and
    /// [`TtiBlock::is_user_data`].
    pub fn extension_block_number(&self) -> u8 {
        self.ebn
    }

    /// Cumulative Status (CS).
    pub fn cumulative_status(&self) -> &CumulativeStatus {
        &self.cs
    }

    /// Time Code In (TCI), validated against the ranges in the spec: hours 0-23, minutes
    /// and seconds 0-59 and frames according to the Disk Format Code.
    ///
    /// On error, [`InvalidFieldValue::value`] gives the value as stored in the file.
    ///
    /// # Example
    ///
    /// ```rust
    /// use ebustl_parser::Time;
    ///
    /// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
    /// let dfc = stl.gsi.disk_format_code();
    /// let time = Time { hours: 10, minutes: 0, seconds: 0, frames: 0 };
    /// assert_eq!(Ok(time), stl.ttis[0].time_code_in(dfc));
    /// ```
    pub fn time_code_in(&self, dfc: &DiskFormatCode) -> Result<Time, InvalidFieldValue<Time>> {
        fields::checked_time_code("TCI", &self.tci, dfc)
    }

    /// Time Code Out (TCO), validated against the ranges in the spec: hours 0-23, minutes
    /// and seconds 0-59 and frames according to the Disk Format Code.
    ///
    /// On error, [`InvalidFieldValue::value`] gives the value as stored in the file.
    ///
    /// # Example
    ///
    /// ```rust
    /// use ebustl_parser::Time;
    ///
    /// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
    /// let dfc = stl.gsi.disk_format_code();
    /// let time = Time { hours: 10, minutes: 0, seconds: 0, frames: 5 };
    /// assert_eq!(Ok(time), stl.ttis[0].time_code_out(dfc));
    /// ```
    pub fn time_code_out(&self, dfc: &DiskFormatCode) -> Result<Time, InvalidFieldValue<Time>> {
        fields::checked_time_code("TCO", &self.tco, dfc)
    }

    /// Vertical Position (VP), the row of the first subtitle row: 1-23 for teletext and 0 up to
    /// MNR for open subtitles.
    pub fn vertical_position(&self) -> u8 {
        self.vp
    }

    /// Justification Code (JC), validated as 00h-03h.
    ///
    /// On error, [`InvalidFieldValue::value`] gives the value as stored in the file.
    ///
    /// # Example
    ///
    /// ```rust
    /// use ebustl_parser::Justification;
    ///
    /// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
    /// assert_eq!(Ok(Justification::Centered), stl.ttis[0].justification());
    /// ```
    pub fn justification(&self) -> Result<Justification, InvalidFieldValue<u8>> {
        match self.jc {
            0x00 => Ok(Justification::Unchanged),
            0x01 => Ok(Justification::Left),
            0x02 => Ok(Justification::Centered),
            0x03 => Ok(Justification::Right),
            jc => Err(InvalidFieldValue::new(
                "JC",
                format!("{jc:02X}h is not 00h-03h"),
                jc,
            )),
        }
    }

    /// Comment Flag (CF), validated as 00h or 01h. `true` if the Text Field contains
    /// comments not intended for transmission.
    ///
    /// On error, [`InvalidFieldValue::value`] gives the value as stored in the file.
    ///
    /// # Example
    ///
    /// ```rust
    /// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
    /// assert_eq!(Ok(false), stl.ttis[0].comment_flag());
    /// ```
    pub fn comment_flag(&self) -> Result<bool, InvalidFieldValue<u8>> {
        match self.cf {
            0x00 => Ok(false),
            0x01 => Ok(true),
            cf => Err(InvalidFieldValue::new(
                "CF",
                format!("{cf:02X}h is not 00h or 01h"),
                cf,
            )),
        }
    }
}

impl TtiBlock {
    /// Creates a teletext subtitle in a single TTI block (EBN FFh), with `idx` as Subtitle
    /// Number.
    ///
    /// Rows are separated by `\n` or `\r\n`. Text that does not fit in one block is
    /// truncated, use [`Stl::add_sub`](crate::Stl::add_sub) to continue it in extension blocks.
    ///
    /// # Example
    ///
    /// ```rust
    /// use ebustl_parser::{CharacterCodeTable, Time, TtiBlock, TtiFormat};
    ///
    /// let time = |seconds| Time { hours: 10, minutes: 0, seconds, frames: 0 };
    /// let format = TtiFormat { jc: 2, vp: 20, dh: false };
    ///
    /// let tti = TtiBlock::new(1, time(1), time(3), "One\nTwo", format, CharacterCodeTable::Latin);
    /// assert_eq!("One\r\nTwo", tti.text());
    /// assert!(tti.is_last_block());
    /// ```
    pub fn new(
        idx: u16,
        tci: Time,
        tco: Time,
        txt: &str,
        opt: TtiFormat,
        cct: CharacterCodeTable,
    ) -> TtiBlock {
        let presentation = Presentation::Teletext {
            double_height: opt.dh,
        };
        TtiBlock {
            sgn: 0,
            sn: idx,
            ebn: EBN_LAST_BLOCK,
            cs: CumulativeStatus::NotPartOfASet,
            tci,
            tco,
            vp: opt.vp,
            jc: opt.jc,
            cf: 0,
            tf: text_field::encode_truncated_text_field(txt, presentation, cct),
            cct, //Needed for Display/Debug without access to GsiBlock
        }
    }

    /// Decodes the Text Field of this single block into a string.
    ///
    /// Control codes are dropped, except CR/LF which becomes `\r\n`. Use
    /// [`TtiBlock::text_elements`] to get the control codes too.
    ///
    /// A subtitle may span several blocks (extension blocks), use
    /// [`Stl::subtitles`](crate::Stl::subtitles) and
    /// [`Subtitle::text`](crate::Subtitle::text) to get the complete text of a subtitle.
    pub fn text(&self) -> String {
        text_field::decode(&self.tf, self.cct)
    }

    /// Decodes the Text Field of this single block into characters and control codes, e.g.
    /// teletext colors, double height and boxing, or italics for open subtitles.
    ///
    /// A subtitle may span several blocks (extension blocks), use
    /// [`Subtitle::text_elements`](crate::Subtitle::text_elements) to get the elements of the
    /// complete subtitle.
    ///
    /// # Example
    ///
    /// ```rust
    /// use ebustl_parser::{TeletextControl, TextElement};
    ///
    /// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
    /// let elements = stl.ttis[2].text_elements();
    /// assert_eq!(TextElement::Teletext(TeletextControl::DoubleHeight), elements[0]);
    /// ```
    pub fn text_elements(&self) -> Vec<TextElement> {
        text_field::decode_elements(&self.tf, self.cct)
    }

    /// The Text Field up to, but not including, the first unused space code (8Fh).
    pub(crate) fn text_field_content(&self) -> &[u8] {
        text_field::content(&self.tf)
    }

    /// Returns `true` for a block carrying user data (EBN FEh). Its Text Field is not
    /// subtitle text and must not affect the rest of the subtitle.
    ///
    /// # Example
    ///
    /// ```rust
    /// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
    /// assert!(!stl.ttis[0].is_user_data());
    /// ```
    pub fn is_user_data(&self) -> bool {
        self.ebn == EBN_USER_DATA
    }

    /// Returns `true` if the Text Field of this block is part of the subtitle text, i.e. the
    /// block is an extension block (EBN 00h-EFh) or the last block of a subtitle (EBN FFh).
    /// User data (FEh) and blocks reserved for future use (F0h-FDh) are not.
    ///
    /// # Example
    ///
    /// ```rust
    /// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
    /// assert!(stl.ttis.iter().all(|tti| tti.is_text_block()));
    /// ```
    pub fn is_text_block(&self) -> bool {
        self.ebn <= EBN_LAST_EXTENSION || self.ebn == EBN_LAST_BLOCK
    }

    /// Returns `true` for the last block of a subtitle (EBN FFh).
    ///
    /// # Example
    ///
    /// ```rust
    /// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
    /// assert!(stl.ttis[0].is_last_block());
    /// ```
    pub fn is_last_block(&self) -> bool {
        self.ebn == EBN_LAST_BLOCK
    }

    /// Returns `true` if the Comment Flag (CF 01h) marks the Text Field as comments, which
    /// are not intended for transmission.
    ///
    /// # Example
    ///
    /// ```rust
    /// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
    /// assert!(!stl.ttis[0].is_comment());
    /// ```
    pub fn is_comment(&self) -> bool {
        self.cf == CF_COMMENT
    }

    #[allow(clippy::vec_init_then_push)]
    pub(crate) fn serialize(&self) -> Vec<u8> {
        let mut res = vec![];
        res.push(self.sgn);
        res.push((self.sn & 0xff) as u8);
        res.push((self.sn >> 8) as u8);
        res.push(self.ebn);
        res.push(self.cs.serialize());
        res.extend(self.tci.serialize().iter().cloned());
        res.extend(self.tco.serialize().iter().cloned());
        res.push(self.vp);
        res.push(self.jc);
        res.push(self.cf);
        res.extend(self.tf.iter().cloned());
        res
    }
}

/// Highest Extension Block Number used for extension blocks
const EBN_LAST_EXTENSION: u8 = 0xEF;
/// Maximum number of text blocks in a subtitle: extension blocks 00h-EFh and the last block
pub(crate) const MAX_TEXT_BLOCKS: usize = EBN_LAST_EXTENSION as usize + 2;
/// Extension Block Number of a block carrying user data
const EBN_USER_DATA: u8 = 0xFE;
/// Extension Block Number of the last block of a subtitle
pub(crate) const EBN_LAST_BLOCK: u8 = 0xFF;
/// Comment Flag value for a Text Field containing comments
const CF_COMMENT: u8 = 0x01;

impl fmt::Debug for TtiBlock {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "\n{}-->{} sgn:{} sn:{} ebn:{} cs:{:?} vp:{} jc:{} cf:{} [{}]",
            self.tci,
            self.tco,
            self.sgn,
            self.sn,
            self.ebn,
            self.cs,
            self.vp,
            self.jc,
            self.cf,
            self.text()
        )
    }
}

impl fmt::Display for TtiBlock {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "\n{} {} {} {} {:?} [{}]",
            self.tci,
            self.sgn,
            self.sn,
            self.ebn,
            self.cs,
            self.text()
        )
    }
}