mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 03:07:13 +00:00
139 lines
4.9 KiB
Rust
139 lines
4.9 KiB
Rust
//! SubRip, and the WebVTT that is close enough to share its reader.
|
|
//!
|
|
//! A port of the parser the podcast archive was indexed with, quirks kept:
|
|
//! the timestamp line is found by looking for an arrow anywhere in the block
|
|
//! rather than at a fixed position, only the first comma in a timestamp
|
|
//! becomes a point, and a block whose times do not read as numbers is
|
|
//! dropped whole rather than guessed at.
|
|
|
|
use std::sync::LazyLock;
|
|
|
|
use regex::Regex;
|
|
|
|
use crate::cue::Cue;
|
|
use crate::jsnum;
|
|
use crate::text;
|
|
|
|
/// Blocks are separated by one or more blank lines.
|
|
static BLOCKS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\n\n+").expect("a valid pattern"));
|
|
|
|
/// Parse a SubRip file.
|
|
pub fn parse(content: &str) -> Vec<Cue> {
|
|
let mut cues = Vec::new();
|
|
for block in BLOCKS.split(content) {
|
|
let lines: Vec<&str> = block.trim_matches(textkit::is_space).split('\n').collect();
|
|
let Some(at) = lines.iter().position(|line| line.contains("-->")) else {
|
|
continue;
|
|
};
|
|
let Some((start, end)) = times(lines[at]) else {
|
|
continue;
|
|
};
|
|
let text = text::clean(&lines[at + 1..].join(" "));
|
|
if !text.is_empty() {
|
|
cues.push(Cue::new(start, end, text));
|
|
}
|
|
}
|
|
cues
|
|
}
|
|
|
|
/// Read a timestamp line, or `None` if either end is not a number.
|
|
///
|
|
/// Each half is reduced to the one token that could be a timestamp, which is
|
|
/// what lets the same reader take a WebVTT cue line — where the end time is
|
|
/// followed by positioning settings — and an old SubRip file with screen
|
|
/// coordinates after it. See `parity/KNOWN.md`: the original reads each half
|
|
/// whole and drops such a cue entirely.
|
|
fn times(line: &str) -> Option<(f64, f64)> {
|
|
let (before, after) = line.split_once("-->")?;
|
|
let start = timestamp(before.split_whitespace().next_back().unwrap_or(""));
|
|
let end = timestamp(after.split_whitespace().next().unwrap_or(""));
|
|
(start.is_finite() && end.is_finite()).then_some((start, end))
|
|
}
|
|
|
|
/// Seconds from a `HH:MM:SS,mmm` or `MM:SS.mmm` timestamp.
|
|
///
|
|
/// Only the first comma is replaced, exactly as the original does — a
|
|
/// timestamp written with a comma for the thousands separator as well as for
|
|
/// the decimal point therefore parses as far as the second comma and no
|
|
/// further.
|
|
pub fn timestamp(text: &str) -> f64 {
|
|
let text = text.trim_matches(textkit::is_space);
|
|
let cleaned = text.replacen(',', ".", 1);
|
|
let parts: Vec<&str> = cleaned.split(':').collect();
|
|
match parts.len() {
|
|
3 => {
|
|
jsnum::number(parts[0]) * 3600.0
|
|
+ jsnum::number(parts[1]) * 60.0
|
|
+ jsnum::parse_float(parts[2])
|
|
}
|
|
2 => jsnum::number(parts[0]) * 60.0 + jsnum::parse_float(parts[1]),
|
|
_ => f64::NAN,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_timestamp_is_read_in_seconds() {
|
|
assert_eq!(timestamp("00:01:23,456"), 83.456);
|
|
assert_eq!(timestamp("01:00:00,000"), 3600.0);
|
|
assert_eq!(timestamp("02:03.500"), 123.5);
|
|
}
|
|
|
|
#[test]
|
|
fn a_timestamp_that_is_not_one_is_not_a_number() {
|
|
assert!(timestamp("").is_nan());
|
|
assert!(timestamp("nonsense").is_nan());
|
|
assert!(timestamp("00:00:00:00").is_nan());
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_parses_into_cues() {
|
|
let srt = "1\n00:00:01,000 --> 00:00:03,000\nThe mill stood at the northern end.\n\n\
|
|
2\n00:00:03,000 --> 00:00:05,500\nIts wheel was undershot,\nwhich was wasteful.\n";
|
|
let cues = parse(srt);
|
|
assert_eq!(cues.len(), 2);
|
|
assert_eq!(cues[0].start, 1.0);
|
|
assert_eq!(cues[0].end, 3.0);
|
|
assert_eq!(cues[0].text, "The mill stood at the northern end.");
|
|
assert_eq!(cues[1].text, "Its wheel was undershot, which was wasteful.");
|
|
}
|
|
|
|
#[test]
|
|
fn a_block_with_no_arrow_is_skipped() {
|
|
assert!(parse("WEBVTT\n\nsome preamble\n").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_block_with_unreadable_times_is_dropped_whole() {
|
|
let srt = "1\nnonsense --> also nonsense\ntext\n\n\
|
|
2\n00:00:03,000 --> 00:00:05,000\nkept\n";
|
|
let cues = parse(srt);
|
|
assert_eq!(cues.len(), 1);
|
|
assert_eq!(cues[0].text, "kept");
|
|
}
|
|
|
|
#[test]
|
|
fn a_block_with_no_text_is_dropped() {
|
|
assert!(parse("1\n00:00:01,000 --> 00:00:03,000\n[Music]\n").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn several_blank_lines_still_separate_one_block_from_the_next() {
|
|
let srt = "1\n00:00:01,000 --> 00:00:02,000\none\n\n\n\n\
|
|
2\n00:00:02,000 --> 00:00:03,000\ntwo\n";
|
|
assert_eq!(parse(srt).len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn webvtt_timing_and_settings_read_too() {
|
|
let vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 line:0 position:20%\nThe mill.\n";
|
|
let cues = parse(vtt);
|
|
assert_eq!(cues.len(), 1);
|
|
assert_eq!(cues[0].start, 1.0);
|
|
assert_eq!(cues[0].end, 3.0);
|
|
}
|
|
}
|