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:
Miguel Palhas
2026-08-24 22:21:09 +01:00
parent cdd6133bce
commit cd304d552c
+274
View File
@@ -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![]);
}
}