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