feat(arr): parse and render SRT cues in arr-subs
Translation reassembles translated text onto original timings (§15), so cues need a structured form. Parsing is tolerant of real files (CRLF, BOM, missing indices, dot milliseconds); rendering is strict and renumbers from 1.
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
//! SRT cues: the unit translation works in.
|
||||
//!
|
||||
//! A cue is a timing span and the text shown during it. Translation sends the
|
||||
//! text out and reassembles the reply onto the original timings (DESIGN.md
|
||||
//! §15), so the timings have to survive the round trip untouched — which is
|
||||
//! why they are parsed into [`Cue`]s here rather than treated as opaque lines.
|
||||
//!
|
||||
//! Parsing is tolerant of what real files contain — CRLF, a BOM, missing
|
||||
//! index lines, `.` instead of `,` before the milliseconds — and rendering is
|
||||
//! strict: indices renumbered from 1, `,` milliseconds, LF line endings. Text
|
||||
//! is `&str`, not bytes: decoding a provider's Latin-1 file is the caller's
|
||||
//! problem, stated where the bytes arrive ([`Fetched::content`]).
|
||||
//!
|
||||
//! [`Fetched::content`]: crate::model::Fetched
|
||||
|
||||
use std::{fmt, time::Duration};
|
||||
|
||||
/// One subtitle cue: when it shows, and what it says.
|
||||
///
|
||||
/// No index field. SRT indices carry no information — renderers ignore them —
|
||||
/// and keeping them would invite gaps and duplicates from sloppy files into
|
||||
/// every consumer. Position in the `Vec` is the identity, and
|
||||
/// [`render`] renumbers from 1.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Cue {
|
||||
/// When the cue appears.
|
||||
pub start: Duration,
|
||||
/// When it disappears.
|
||||
pub end: Duration,
|
||||
/// The text shown, lines joined with `\n`. This is the only part of a cue
|
||||
/// that ever leaves arr (DESIGN.md §15).
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Why a file failed to parse as SRT.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("line {line}: {detail}")]
|
||||
pub struct ParseError {
|
||||
/// The 1-based line the problem was found on.
|
||||
pub line: usize,
|
||||
/// What was wrong with it.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// Parse SRT text into cues.
|
||||
///
|
||||
/// Accepts CRLF and LF, a leading BOM, blocks with or without an index line,
|
||||
/// and `.` as the millisecond separator. Cues come back in file order,
|
||||
/// original indices discarded.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`ParseError`] when a block has no timing line or a timestamp does not
|
||||
/// parse. A file that is not SRT at all fails on its first block rather than
|
||||
/// producing an empty translation source.
|
||||
pub fn parse(input: &str) -> Result<Vec<Cue>, ParseError> {
|
||||
let input = input.strip_prefix('\u{feff}').unwrap_or(input);
|
||||
let mut cues = Vec::new();
|
||||
|
||||
let mut lines = input.lines().enumerate().peekable();
|
||||
while let Some((index, line)) = lines.next() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// An index line is digits alone; the timing line follows. A block may
|
||||
// omit the index, so a line that already carries `-->` is the timing.
|
||||
let (timing_index, timing) = if line.contains("-->") {
|
||||
(index, line)
|
||||
} else if line.trim().chars().all(|c| c.is_ascii_digit()) {
|
||||
match lines.next() {
|
||||
Some((next_index, next)) if next.contains("-->") => (next_index, next),
|
||||
Some((next_index, _)) => {
|
||||
return Err(ParseError {
|
||||
line: next_index + 1,
|
||||
detail: "expected a timing line after the cue index".to_owned(),
|
||||
})
|
||||
}
|
||||
None => {
|
||||
return Err(ParseError {
|
||||
line: index + 1,
|
||||
detail: "file ends after a cue index".to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(ParseError {
|
||||
line: index + 1,
|
||||
detail: format!("expected a cue index or timing line, got {line:?}"),
|
||||
});
|
||||
};
|
||||
|
||||
let (start, end) = parse_timing(timing, timing_index + 1)?;
|
||||
|
||||
let mut text_lines = Vec::new();
|
||||
while let Some((_, text)) = lines.peek() {
|
||||
if text.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
text_lines.push(*text);
|
||||
lines.next();
|
||||
}
|
||||
|
||||
cues.push(Cue {
|
||||
start,
|
||||
end,
|
||||
text: text_lines.join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(cues)
|
||||
}
|
||||
|
||||
/// Render cues back to SRT, indices renumbered from 1.
|
||||
#[must_use]
|
||||
pub fn render(cues: &[Cue]) -> String {
|
||||
use fmt::Write as _;
|
||||
|
||||
let mut out = String::new();
|
||||
for (position, cue) in cues.iter().enumerate() {
|
||||
if position > 0 {
|
||||
let _ = writeln!(out);
|
||||
}
|
||||
let _ = writeln!(out, "{}", position + 1);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{} --> {}",
|
||||
Timestamp(cue.start),
|
||||
Timestamp(cue.end)
|
||||
);
|
||||
let _ = writeln!(out, "{}", cue.text);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A `Duration` in SRT's `HH:MM:SS,mmm` spelling.
|
||||
struct Timestamp(Duration);
|
||||
|
||||
impl fmt::Display for Timestamp {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let millis = self.0.as_millis();
|
||||
let (seconds, millis) = (millis / 1_000, millis % 1_000);
|
||||
let (minutes, seconds) = (seconds / 60, seconds % 60);
|
||||
let (hours, minutes) = (minutes / 60, minutes % 60);
|
||||
write!(formatter, "{hours:02}:{minutes:02}:{seconds:02},{millis:03}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `HH:MM:SS,mmm --> HH:MM:SS,mmm` into its two instants.
|
||||
///
|
||||
/// Anything after the second timestamp — some files append position hints —
|
||||
/// is ignored.
|
||||
fn parse_timing(line: &str, line_number: usize) -> Result<(Duration, Duration), ParseError> {
|
||||
let error = |detail: String| ParseError {
|
||||
line: line_number,
|
||||
detail,
|
||||
};
|
||||
|
||||
let (start, rest) = line
|
||||
.split_once("-->")
|
||||
.ok_or_else(|| error("expected a timing line".to_owned()))?;
|
||||
let end = rest.split_whitespace().next().unwrap_or("");
|
||||
|
||||
Ok((
|
||||
parse_timestamp(start.trim()).map_err(error)?,
|
||||
parse_timestamp(end).map_err(error)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Parse one `HH:MM:SS,mmm` timestamp; `.` accepted for the comma.
|
||||
fn parse_timestamp(text: &str) -> Result<Duration, String> {
|
||||
let bad = || format!("bad timestamp {text:?}");
|
||||
|
||||
let mut clock = text.splitn(3, ':');
|
||||
let (Some(hours), Some(minutes), Some(rest)) = (clock.next(), clock.next(), clock.next())
|
||||
else {
|
||||
return Err(bad());
|
||||
};
|
||||
let (seconds, millis) = rest
|
||||
.split_once([',', '.'])
|
||||
.ok_or_else(bad)?;
|
||||
|
||||
let field = |s: &str| s.trim().parse::<u64>().map_err(|_| bad());
|
||||
let (hours, minutes, seconds, millis) =
|
||||
(field(hours)?, field(minutes)?, field(seconds)?, field(millis)?);
|
||||
if minutes >= 60 || seconds >= 60 || millis >= 1_000 {
|
||||
return Err(bad());
|
||||
}
|
||||
|
||||
Ok(Duration::from_millis(
|
||||
((hours * 60 + minutes) * 60 + seconds) * 1_000 + millis,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{parse, render, Cue};
|
||||
|
||||
const FILE: &str = "1\n00:00:01,000 --> 00:00:02,500\nol\u{e1}\n\n2\n00:00:03,000 --> 00:00:04,000\ntwo lines\nof text\n";
|
||||
|
||||
#[test]
|
||||
fn a_plain_file_parses_into_cues() {
|
||||
let cues = parse(FILE).expect("well-formed SRT");
|
||||
assert_eq!(cues.len(), 2);
|
||||
assert_eq!(cues[0].start, Duration::from_millis(1_000));
|
||||
assert_eq!(cues[0].end, Duration::from_millis(2_500));
|
||||
assert_eq!(cues[0].text, "ol\u{e1}");
|
||||
assert_eq!(cues[1].text, "two lines\nof text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_bom_and_dot_milliseconds_are_tolerated() {
|
||||
let file = "\u{feff}1\r\n00:00:01.000 --> 00:00:02.000\r\nhello\r\n\r\n";
|
||||
let cues = parse(file).expect("real-world SRT");
|
||||
assert_eq!(cues.len(), 1);
|
||||
assert_eq!(cues[0].text, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_without_an_index_line_still_parses() {
|
||||
let file = "00:00:01,000 --> 00:00:02,000\nno index\n";
|
||||
let cues = parse(file).expect("index lines are optional");
|
||||
assert_eq!(cues.len(), 1);
|
||||
assert_eq!(cues[0].text, "no index");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_indices_are_discarded_and_render_renumbers() {
|
||||
let file = "7\n00:00:01,000 --> 00:00:02,000\nfirst\n\n9\n00:00:03,000 --> 00:00:04,000\nsecond\n";
|
||||
let cues = parse(file).expect("gappy indices parse");
|
||||
let rendered = render(&cues);
|
||||
assert!(rendered.starts_with("1\n00:00:01,000 --> 00:00:02,000\nfirst\n"));
|
||||
assert!(rendered.contains("\n2\n00:00:03,000 --> 00:00:04,000\nsecond\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_and_render_round_trip() {
|
||||
let cues = parse(FILE).expect("well-formed SRT");
|
||||
assert_eq!(render(&cues), FILE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hours_render_and_survive_the_round_trip() {
|
||||
let cue = Cue {
|
||||
start: Duration::from_millis(3_600_000 + 61_001),
|
||||
end: Duration::from_millis(3_600_000 + 62_002),
|
||||
text: "late".to_owned(),
|
||||
};
|
||||
let rendered = render(std::slice::from_ref(&cue));
|
||||
assert!(rendered.contains("01:01:01,001 --> 01:01:02,002"));
|
||||
assert_eq!(parse(&rendered).expect("own output parses"), vec![cue]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_timestamp_names_its_line() {
|
||||
let file = "1\n00:00:01,000 --> nonsense\ntext\n";
|
||||
let error = parse(file).expect_err("timestamp is nonsense");
|
||||
assert_eq!(error.line, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_srt_fails_rather_than_parsing_empty() {
|
||||
assert!(parse("WEBVTT\n\n00:01.000 --> 00:02.000\nhi\n").is_err());
|
||||
assert!(parse("{1}{50}not srt at all").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_file_is_an_empty_cue_list() {
|
||||
assert_eq!(parse("").expect("nothing to object to"), vec![]);
|
||||
assert_eq!(parse("\n\n").expect("blank lines only"), vec![]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user