use logos::{Lexer, Logos};
use crate::{
error::*,
types::{Millisecond, Minute, Second},
utils::Lines,
};
pub use types::{
Align, Anchor, Block, Cue, CueId, CueOptions, Header, Hour, Line, LineAlign, LineValue,
Percentage, Position, PositionAlign, Region, RegionId, Scroll, Size, Timestamp, Vertical,
};
mod types;
pub mod cue;
#[cfg(any(feature = "alloc", feature = "std"))]
mod html5_entities;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ParseVttError {
#[error(transparent)]
ParseMinute(#[from] ParseMinuteError),
#[error(transparent)]
ParseSecond(#[from] ParseSecondError),
#[error(transparent)]
ParseHour(#[from] ParseHourError),
#[error(transparent)]
ParseMillisecond(#[from] ParseMillisecondError),
#[error("missing WEBVTT signature")]
MissingSignature,
#[error("invalid character after WEBVTT signature")]
InvalidSignature,
#[error("invalid timestamp: {0}")]
InvalidTimestamp(TimestampError),
#[error("invalid timing line: missing '-->' separator")]
InvalidTimingLine,
#[error("unclosed duration, missing end timestamp")]
UnclosedDuration,
#[error("unexpected token: {0}")]
Unknown(&'static str),
}
impl Default for ParseVttError {
fn default() -> Self {
Self::Unknown("unknown lexer error")
}
}
#[derive(Debug, Logos, PartialEq)]
#[logos(error = ParseVttError)]
enum Token {
#[regex(
r"(?:[0-9]+:)?[0-5][0-9]:[0-5][0-9]\.[0-9]{3}[ \t\x0C]+-->[ \t\x0C]+(?:[0-9]+:)?[0-5][0-9]:[0-5][0-9]\.[0-9]{3}",
parse_timing,
)]
TimingLine((Timestamp, Timestamp)),
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn parse_timing(lex: &mut Lexer<'_, Token>) -> Result<(Timestamp, Timestamp), ParseVttError> {
let s = lex.slice();
let (start_str, end_str) = split_arrow(s)?;
let start = parse_timestamp(start_str)?;
let end = parse_timestamp(end_str)?;
Ok((start, end))
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn split_arrow(s: &str) -> Result<(&str, &str), ParseVttError> {
let arrow = s.find("-->").ok_or(ParseVttError::InvalidTimingLine)?;
let start = s[..arrow].trim();
let end = s[arrow + 3..].trim();
if start.is_empty() || end.is_empty() {
return Err(ParseVttError::UnclosedDuration);
}
Ok((start, end))
}
#[inline]
pub(crate) fn parse_timestamp(s: &str) -> Result<Timestamp, ParseVttError> {
let b = s.as_bytes();
let len = b.len();
if len < 9 {
return Err(ParseVttError::InvalidTimestamp(
TimestampError::InvalidLength,
));
}
let millis = Millisecond(vtt_digit3(&b[len - 3..]));
let seconds = Second(vtt_digit2(&b[len - 6..len - 4]));
let minutes = Minute(vtt_digit2(&b[len - 9..len - 7]));
let hours = if len > 9 {
let hour_str = &s[..len - 10];
parse_vtt_hour_bytes(hour_str.as_bytes())?
} else {
Hour::new()
};
Ok(Timestamp::from_hmsm(hours, minutes, seconds, millis))
}
#[inline]
pub(crate) fn parse_timestamp_cue(s: &str) -> Result<Timestamp, ParseVttError> {
let b = s.as_bytes();
let len = b.len();
if !(9..=29).contains(&len) || (len > 9 && len < 12) {
return Err(ParseVttError::InvalidTimestamp(
TimestampError::InvalidLength,
));
}
if b[len - 4] != b'.' || b[len - 7] != b':' {
return Err(ParseVttError::InvalidTimestamp(
TimestampError::InvalidFormat,
));
}
let millis_val = vtt_digit3_checked(&b[len - 3..]).ok_or(ParseVttError::InvalidTimestamp(
TimestampError::InvalidDigits,
))?;
let seconds_val = vtt_digit2_checked(&b[len - 6..len - 4]).ok_or(
ParseVttError::InvalidTimestamp(TimestampError::InvalidDigits),
)?;
let minutes_val = vtt_digit2_checked(&b[len - 9..len - 7]).ok_or(
ParseVttError::InvalidTimestamp(TimestampError::InvalidDigits),
)?;
let millis = Millisecond::try_with(millis_val).ok_or(ParseVttError::ParseMillisecond(
ParseMillisecondError::Overflow(millis_val),
))?;
let seconds = Second::try_with(seconds_val).ok_or(ParseVttError::ParseSecond(
ParseSecondError::Overflow(seconds_val),
))?;
let minutes = Minute::try_with(minutes_val).ok_or(ParseVttError::ParseMinute(
ParseMinuteError::Overflow(minutes_val),
))?;
let hours = if len >= 12 {
if b[len - 10] != b':' {
return Err(ParseVttError::InvalidTimestamp(
TimestampError::InvalidFormat,
));
}
let hour_bytes = &b[..len - 10];
parse_vtt_hour_bytes(hour_bytes)?
} else {
Hour::new()
};
Ok(Timestamp::from_hmsm(hours, minutes, seconds, millis))
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn parse_vtt_hour_bytes(b: &[u8]) -> Result<Hour, ParseHourError> {
let mut val: u64 = 0;
for &byte in b {
if !byte.is_ascii_digit() {
return Err(ParseHourError::NotPadded);
}
val = val
.checked_mul(10)
.and_then(|v| v.checked_add((byte - b'0') as u64))
.ok_or(ParseHourError::HourOverflow)?;
}
Ok(Hour(val))
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn vtt_digit2(b: &[u8]) -> u8 {
debug_assert!(b[0].is_ascii_digit() && b[1].is_ascii_digit());
(b[0] - b'0') * 10 + (b[1] - b'0')
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn vtt_digit3(b: &[u8]) -> u16 {
debug_assert!(b[0].is_ascii_digit() && b[1].is_ascii_digit() && b[2].is_ascii_digit());
(b[0] - b'0') as u16 * 100 + (b[1] - b'0') as u16 * 10 + (b[2] - b'0') as u16
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn vtt_digit2_checked(b: &[u8]) -> Option<u8> {
let d0 = b[0].wrapping_sub(b'0');
let d1 = b[1].wrapping_sub(b'0');
if d0 > 9 || d1 > 9 {
return None;
}
Some(d0 * 10 + d1)
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn vtt_digit3_checked(b: &[u8]) -> Option<u16> {
let d0 = b[0].wrapping_sub(b'0');
let d1 = b[1].wrapping_sub(b'0');
let d2 = b[2].wrapping_sub(b'0');
if d0 > 9 || d1 > 9 || d2 > 9 {
return None;
}
Some(d0 as u16 * 100 + d1 as u16 * 10 + d2 as u16)
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn lex(line: &str) -> Result<Option<(Token, usize)>, ParseVttError> {
let mut lexer = Token::lexer(line);
match lexer.next() {
Some(result) => result.map(|t| Some((t, lexer.span().end))),
None => Ok(None),
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn is_timing_line(line: &str) -> bool {
line.contains("-->")
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn strip_leading(line: &str) -> &str {
line
.trim_start_matches('\u{feff}')
.trim_start_matches([' ', '\t', '\x0c'])
}
fn parse_cue_settings<'a>(s: &'a str) -> CueOptions<'a> {
let mut settings = CueOptions::default();
if s.is_empty() {
return settings;
}
for token in s.split([' ', '\t', '\x0c']) {
if token.is_empty() {
continue;
}
let Some((key, value)) = token.split_once(':') else {
continue;
};
if value.is_empty() {
break;
}
match key {
"vertical" => match value {
"rl" => {
settings.set_vertical(Vertical::Rl);
}
"lr" => {
settings.set_vertical(Vertical::Lr);
}
_ => {
continue;
}
},
"line" => {
if let Some((val_str, align_str)) = value.split_once(',') {
let alignment = match align_str {
"start" => LineAlign::Start,
"center" => LineAlign::Center,
"end" => LineAlign::End,
_ => {
continue;
}
};
if let Some(v) = parse_line_value(val_str) {
settings.set_line(Line::with_alignment(v, alignment));
}
} else if let Some(v) = parse_line_value(value) {
settings.set_line(Line::new(v));
}
}
"position" => {
if let Some((val_str, align_str)) = value.split_once(',') {
let alignment = match align_str {
"line-left" => PositionAlign::LineLeft,
"center" => PositionAlign::Center,
"line-right" => PositionAlign::LineRight,
"auto" => PositionAlign::Auto,
_ => {
continue;
}
};
if let Some(pct) = parse_percentage(val_str) {
settings.set_position(Position::with_alignment(pct, alignment));
}
} else if let Some(pct) = parse_percentage(value) {
settings.set_position(Position::new(pct));
}
}
"size" => {
if let Some(pct) = parse_percentage(value) {
settings.set_size(Size::new(pct));
}
}
"align" => match value {
"start" => {
settings.set_align(Align::Start);
}
"center" => {
settings.set_align(Align::Center);
}
"end" => {
settings.set_align(Align::End);
}
"left" => {
settings.set_align(Align::Left);
}
"right" => {
settings.set_align(Align::Right);
}
_ => {
continue;
}
},
"region" => {
settings.set_region(RegionId::new(value));
}
_ => {
}
}
}
settings
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn parse_percentage(s: &str) -> Option<Percentage> {
let s = s.strip_suffix('%')?;
let n: f64 = s.parse().ok()?;
Percentage::try_with(n)
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn parse_line_value(s: &str) -> Option<LineValue> {
if let Some(pct) = parse_percentage(s) {
Some(LineValue::Percentage(pct))
} else {
s.parse().ok().map(LineValue::Number)
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn parse_anchor(s: &str) -> Option<Anchor> {
let (x_str, y_str) = s.split_once(',')?;
let x = parse_percentage(x_str)?;
let y = parse_percentage(y_str)?;
Some(Anchor::new(x, y))
}
fn parse_region_settings<'a>(body: &'a str) -> Region<'a> {
let mut region = Region::default();
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Some((key, value)) = line.split_once(':') else {
continue;
};
if value.is_empty() {
continue;
}
match key {
"id" => {
if !value.contains("-->") {
region.set_id(RegionId::new(value));
}
}
"width" => {
if let Some(pct) = parse_percentage(value) {
region.set_width(pct);
}
}
"lines" => {
if let Ok(n) = value.parse::<u32>() {
region.set_lines(n);
}
}
"regionanchor" => {
if let Some(anchor) = parse_anchor(value) {
region.set_region_anchor(anchor);
}
}
"viewportanchor" => {
if let Some(anchor) = parse_anchor(value) {
region.set_viewport_anchor(anchor);
}
}
"scroll" if value == "up" => {
region.set_scroll(Scroll::Up);
}
_ => {}
}
}
region
}
#[cfg(feature = "std")]
fn format_cue_settings(settings: &CueOptions<'_>, buf: &mut std::vec::Vec<u8>) {
use std::io::Write as _;
if let Some(v) = settings.vertical() {
let _ = write!(buf, " vertical:{v}");
}
if let Some(line) = settings.line() {
match line.value() {
LineValue::Percentage(p) => {
let _ = write!(buf, " line:{p}%");
}
LineValue::Number(n) => {
let _ = write!(buf, " line:{n}");
}
}
if let Some(align) = line.alignment() {
let _ = write!(buf, ",{align}");
}
}
if let Some(pos) = settings.position() {
let _ = write!(buf, " position:{}%", pos.value());
if let Some(align) = pos.alignment() {
let _ = write!(buf, ",{align}");
}
}
if let Some(size) = settings.size() {
let _ = write!(buf, " size:{}%", size.value());
}
if let Some(align) = settings.align() {
let _ = write!(buf, " align:{align}");
}
if let Some(region) = settings.region() {
let _ = write!(buf, " region:{region}");
}
}
#[cfg(feature = "std")]
fn format_region<W: std::io::Write>(region: &Region<'_>, w: &mut W) -> std::io::Result<()> {
let id = region.id().as_str();
if !id.is_empty() {
writeln!(w, "id:{id}")?;
}
let default = Region::default();
if region.width() != default.width() {
writeln!(w, "width:{}%", region.width())?;
}
if region.lines() != default.lines() {
writeln!(w, "lines:{}", region.lines())?;
}
if region.region_anchor() != default.region_anchor() {
let a = region.region_anchor();
writeln!(w, "regionanchor:{}%,{}%", a.x(), a.y())?;
}
if region.viewport_anchor() != default.viewport_anchor() {
let a = region.viewport_anchor();
writeln!(w, "viewportanchor:{}%,{}%", a.x(), a.y())?;
}
if region.scroll() != default.scroll() {
writeln!(w, "scroll:{}", region.scroll())?;
}
Ok(())
}
enum State<'a> {
Signature,
Header,
BlockStart,
CueBody(CueBodyState<'a>),
NoteBody(usize, usize),
StyleBody(usize, usize),
RegionBody(usize, usize),
Done,
}
struct CueBodyState<'a> {
header: Header<'a>,
start: usize,
end: usize,
}
impl<'a> CueBodyState<'a> {
#[cfg_attr(not(tarpaulin), inline(always))]
const fn new(header: Header<'a>, start: usize, end: usize) -> Self {
Self { header, start, end }
}
}
pub struct Parser<'a> {
input: &'a str,
lines: Lines<'a>,
state: State<'a>,
seen_cue: bool,
pending_line: Option<&'a str>,
}
impl<'a> Parser<'a> {
pub fn new(input: &'a str) -> Self {
Self {
input,
lines: Lines::new(input),
state: State::Signature,
seen_cue: false,
pending_line: None,
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn next_line(&mut self) -> Option<&'a str> {
self.pending_line.take().or_else(|| self.lines.next())
}
fn skip_to_block_boundary(&mut self) {
loop {
let Some(line) = self.lines.next() else {
return;
};
if line.is_empty() {
return;
}
if is_timing_line(line) {
self.pending_line = Some(line);
return;
}
}
}
}
fn try_parse_timing<'a>(line: &'a str, input: &'a str) -> Option<(Header<'a>, usize)> {
let stripped = strip_leading(line);
if !is_timing_line(stripped) {
return None;
}
match lex(stripped) {
Ok(Some((Token::TimingLine((start, end)), matched_end))) => {
let mut header = Header::new(start, end);
let settings_str = stripped[matched_end..].trim();
let settings = parse_cue_settings(settings_str);
if settings != CueOptions::default() {
header.set_settings(settings);
}
let offset = line.as_ptr() as usize - input.as_ptr() as usize + line.len();
Some((header, offset))
}
_ => None,
}
}
impl<'a> Iterator for Parser<'a> {
type Item = Result<Block<'a, &'a str>, ParseVttError>;
fn next(&mut self) -> Option<Self::Item> {
loop {
match self.state {
State::Done => return None,
State::Signature => {
let Some(line) = self.lines.next() else {
self.state = State::Done;
return Some(Err(ParseVttError::MissingSignature));
};
let line = line.trim_start_matches('\u{feff}');
if !line.starts_with("WEBVTT") {
self.state = State::Done;
return Some(Err(ParseVttError::MissingSignature));
}
let rest = &line[6..];
if !rest.is_empty() && !rest.starts_with(' ') && !rest.starts_with('\t') {
self.state = State::Done;
return Some(Err(ParseVttError::InvalidSignature));
}
self.state = State::Header;
}
State::Header => {
let Some(line) = self.lines.next() else {
self.state = State::Done;
return None;
};
if line.is_empty() {
self.state = State::BlockStart;
} else if is_timing_line(line) {
self.pending_line = Some(line);
self.state = State::BlockStart;
}
}
State::BlockStart => {
let Some(line) = self.next_line() else {
self.state = State::Done;
return None;
};
let trimmed = line.trim_start_matches('\u{feff}');
if trimmed.is_empty() {
continue;
}
if trimmed == "NOTE" || trimmed.starts_with("NOTE ") || trimmed.starts_with("NOTE\t") {
let after_note = if trimmed == "NOTE" {
""
} else {
trimmed[5..].trim_start()
};
if after_note.is_empty() {
let offset = line.as_ptr() as usize - self.input.as_ptr() as usize + line.len();
self.state = State::NoteBody(offset, offset);
} else {
let body_start = after_note.as_ptr() as usize - self.input.as_ptr() as usize;
let body_end = body_start + after_note.len();
self.state = State::NoteBody(body_start, body_end);
}
continue;
}
if (trimmed == "STYLE" || trimmed.starts_with("STYLE ") || trimmed.starts_with("STYLE\t"))
&& !self.seen_cue
{
let offset = line.as_ptr() as usize - self.input.as_ptr() as usize + line.len();
self.state = State::StyleBody(offset, offset);
continue;
}
if (trimmed == "REGION"
|| trimmed.starts_with("REGION ")
|| trimmed.starts_with("REGION\t"))
&& !self.seen_cue
{
let offset = line.as_ptr() as usize - self.input.as_ptr() as usize + line.len();
self.state = State::RegionBody(offset, offset);
continue;
}
if is_timing_line(trimmed) {
if let Some((header, offset)) = try_parse_timing(line, self.input) {
self.state = State::CueBody(CueBodyState::new(header, offset, offset));
self.seen_cue = true;
} else {
self.skip_to_block_boundary();
}
continue;
}
let identifier_line = trimmed;
let Some(next_line) = self.lines.next() else {
self.state = State::Done;
return None;
};
if is_timing_line(next_line) {
if let Some((mut header, offset)) = try_parse_timing(next_line, self.input) {
header.set_identifier(CueId::new(identifier_line));
self.state = State::CueBody(CueBodyState::new(header, offset, offset));
self.seen_cue = true;
} else {
self.skip_to_block_boundary();
}
} else if next_line.is_empty() {
continue;
} else {
self.pending_line = Some(next_line);
continue;
}
}
State::CueBody(ref mut body) => {
let CueBodyState { header, start, end } = body;
let Some(line) = self.lines.next() else {
let body_text = body_slice(self.input, *start, *end);
let entry = Cue::new(header.clone(), body_text);
self.state = State::Done;
return Some(Ok(Block::Cue(entry)));
};
if line.is_empty() {
let body_text = body_slice(self.input, *start, *end);
let entry = Cue::new(header.clone(), body_text);
self.state = State::BlockStart;
return Some(Ok(Block::Cue(entry)));
}
if is_timing_line(line) {
let body_text = body_slice(self.input, *start, *end);
let entry = Cue::new(header.clone(), body_text);
self.pending_line = Some(line);
self.state = State::BlockStart;
return Some(Ok(Block::Cue(entry)));
}
let line_offset = line.as_ptr() as usize - self.input.as_ptr() as usize;
if *start == *end {
*start = line_offset;
}
*end = line_offset + line.len();
}
State::NoteBody(ref mut start, ref mut end) => {
let Some(line) = self.lines.next() else {
let body_text = body_slice(self.input, *start, *end);
self.state = State::Done;
return Some(Ok(Block::Note(body_text)));
};
if line.is_empty() {
let body_text = body_slice(self.input, *start, *end);
self.state = State::BlockStart;
return Some(Ok(Block::Note(body_text)));
}
let line_offset = line.as_ptr() as usize - self.input.as_ptr() as usize;
if *start == *end {
*start = line_offset;
}
*end = line_offset + line.len();
}
State::StyleBody(ref mut start, ref mut end) => {
let Some(line) = self.lines.next() else {
let body_text = body_slice(self.input, *start, *end);
self.state = State::Done;
return Some(Ok(Block::Style(body_text)));
};
if line.is_empty() {
let body_text = body_slice(self.input, *start, *end);
self.state = State::BlockStart;
return Some(Ok(Block::Style(body_text)));
}
let line_offset = line.as_ptr() as usize - self.input.as_ptr() as usize;
if *start == *end {
*start = line_offset;
}
*end = line_offset + line.len();
}
State::RegionBody(ref mut start, ref mut end) => {
let Some(line) = self.lines.next() else {
let body_text = body_slice(self.input, *start, *end);
let region = parse_region_settings(body_text);
self.state = State::Done;
return Some(Ok(Block::Region(region)));
};
if line.is_empty() {
let body_text = body_slice(self.input, *start, *end);
let region = parse_region_settings(body_text);
self.state = State::BlockStart;
return Some(Ok(Block::Region(region)));
}
let line_offset = line.as_ptr() as usize - self.input.as_ptr() as usize;
if *start == *end {
*start = line_offset;
}
*end = line_offset + line.len();
}
}
}
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn body_slice(input: &str, start: usize, end: usize) -> &str {
if start >= end { "" } else { &input[start..end] }
}
#[cfg(feature = "std")]
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
pub trait CueBody: sealed::Sealed {
fn write_body<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()>;
fn is_empty(&self) -> bool;
}
#[cfg(feature = "std")]
mod sealed {
use super::cue::*;
pub trait Sealed {}
impl Sealed for str {}
impl Sealed for std::string::String {}
impl<'a, C: Nodes<'a>> Sealed for CueText<'a, C> {}
impl Sealed for [Node<'_>] {}
impl Sealed for Node<'_> {}
impl<T: Sealed> Sealed for Option<T> {}
impl Sealed for super::Timestamp {}
impl Sealed for super::cue::CueStr<'_> {}
impl<C> Sealed for super::cue::TagNode<'_, C> {}
}
#[cfg(feature = "std")]
const _: () = {
use cue::{CueText, Node, Nodes};
impl CueBody for str {
#[cfg_attr(not(tarpaulin), inline(always))]
fn write_body<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
w.write_all(self.as_bytes())
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn is_empty(&self) -> bool {
self.is_empty()
}
}
impl CueBody for std::string::String {
#[cfg_attr(not(tarpaulin), inline(always))]
fn write_body<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
w.write_all(self.as_bytes())
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn is_empty(&self) -> bool {
self.is_empty()
}
}
impl<'a, C: Nodes<'a>> CueBody for CueText<'a, C> {
#[cfg_attr(not(tarpaulin), inline(always))]
fn write_body<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
write!(w, "{}", self)
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn is_empty(&self) -> bool {
self.children().as_nodes().is_empty()
}
}
impl CueBody for [Node<'_>] {
#[cfg_attr(not(tarpaulin), inline(always))]
fn write_body<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
for node in self {
write!(w, "{}", node)?;
}
Ok(())
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn is_empty(&self) -> bool {
self.is_empty()
}
}
impl CueBody for Node<'_> {
#[cfg_attr(not(tarpaulin), inline(always))]
fn write_body<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
write!(w, "{}", self)
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn is_empty(&self) -> bool {
false
}
}
impl CueBody for Timestamp {
#[cfg_attr(not(tarpaulin), inline(always))]
fn write_body<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
w.write_all(self.encode().as_str().as_bytes())
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn is_empty(&self) -> bool {
false
}
}
impl CueBody for cue::CueStr<'_> {
#[cfg_attr(not(tarpaulin), inline(always))]
fn write_body<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
w.write_all(self.normalize().as_bytes())
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn is_empty(&self) -> bool {
false
}
}
impl<'a, C> CueBody for cue::TagNode<'a, C>
where
C: AsRef<[Node<'a>]>,
{
#[cfg_attr(not(tarpaulin), inline(always))]
fn write_body<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
write!(w, "{self}")
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn is_empty(&self) -> bool {
false
}
}
impl<T: CueBody> CueBody for Option<T> {
#[cfg_attr(not(tarpaulin), inline(always))]
fn write_body<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
if let Some(node) = self {
node.write_body(w)
} else {
Ok(())
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn is_empty(&self) -> bool {
self.as_ref().is_some_and(|t| t.is_empty())
}
}
};
#[cfg(feature = "std")]
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
pub struct Writer<W> {
inner: W,
has_written_signature: bool,
has_written_block: bool,
}
#[cfg(feature = "std")]
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
const _: () = {
use std::io::{self, Write};
impl<W: Write> Writer<W> {
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn new(inner: W) -> Self {
Self {
inner,
has_written_signature: false,
has_written_block: false,
}
}
pub fn write_header(&mut self, header_text: Option<&str>) -> io::Result<()> {
if self.has_written_signature {
return Ok(());
}
self.has_written_signature = true;
self.inner.write_all(b"WEBVTT")?;
if let Some(text) = header_text
&& !text.is_empty()
{
self.inner.write_all(b" ")?;
self.inner.write_all(text.as_bytes())?;
}
self.inner.write_all(b"\n")
}
pub fn write<T: AsRef<str>>(&mut self, block: &Block<'_, T>) -> io::Result<()> {
if !self.has_written_signature {
self.write_header(None)?;
}
self.inner.write_all(b"\n")?;
match block {
Block::Cue(cue) => {
let header = cue.header_ref();
if let Some(id) = header.identifier() {
self.inner.write_all(id.as_str().as_bytes())?;
self.inner.write_all(b"\n")?;
}
self
.inner
.write_all(header.start().encode().as_str().as_bytes())?;
self.inner.write_all(b" --> ")?;
self
.inner
.write_all(header.end().encode().as_str().as_bytes())?;
if let Some(settings) = header.settings() {
let mut settings_buf = std::vec::Vec::new();
format_cue_settings(settings, &mut settings_buf);
self.inner.write_all(&settings_buf)?;
}
self.inner.write_all(b"\n")?;
let body = cue.body_ref().as_ref();
if !body.is_empty() {
self.inner.write_all(body.as_bytes())?;
self.inner.write_all(b"\n")?;
}
}
Block::Note(text) => {
let text = text.as_ref();
if text.is_empty() {
self.inner.write_all(b"NOTE\n")?;
} else {
self.inner.write_all(b"NOTE\n")?;
self.inner.write_all(text.as_bytes())?;
self.inner.write_all(b"\n")?;
}
}
Block::Style(text) => {
self.inner.write_all(b"STYLE\n")?;
let text = text.as_ref();
if !text.is_empty() {
self.inner.write_all(text.as_bytes())?;
self.inner.write_all(b"\n")?;
}
}
Block::Region(region) => {
self.inner.write_all(b"REGION\n")?;
format_region(region, &mut self.inner)?;
}
}
self.has_written_block = true;
Ok(())
}
pub fn write_cue<B: CueBody + ?Sized>(
&mut self,
header: &Header<'_>,
body: &B,
) -> io::Result<()> {
if !self.has_written_signature {
self.write_header(None)?;
}
self.inner.write_all(b"\n")?;
if let Some(id) = header.identifier() {
self.inner.write_all(id.as_str().as_bytes())?;
self.inner.write_all(b"\n")?;
}
self
.inner
.write_all(header.start().encode().as_str().as_bytes())?;
self.inner.write_all(b" --> ")?;
self
.inner
.write_all(header.end().encode().as_str().as_bytes())?;
if let Some(settings) = header.settings() {
let mut settings_buf = std::vec::Vec::new();
format_cue_settings(settings, &mut settings_buf);
self.inner.write_all(&settings_buf)?;
}
self.inner.write_all(b"\n")?;
if !body.is_empty() {
body.write_body(&mut self.inner)?;
self.inner.write_all(b"\n")?;
}
self.has_written_block = true;
Ok(())
}
pub fn write_all<'b, 'c, T, I>(&mut self, blocks: I) -> io::Result<()>
where
T: AsRef<str> + 'b,
I: IntoIterator<Item = &'b Block<'c, T>>,
'c: 'b,
{
for block in blocks {
self.write(block)?;
}
Ok(())
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub fn into_inner(self) -> W {
self.inner
}
}
};