cuekit and cuenorm: three subtitle formats, one cue stream

This commit is contained in:
funkpower
2026-09-19 11:54:36 -04:00
parent 33c2e40a1a
commit c7d0767376
42 changed files with 1433 additions and 68 deletions
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "cuekit"
description = "The cue model everything timed shares, and the subtitle formats it is read from."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
corpus-core.workspace = true
regex.workspace = true
schemars.workspace = true
serde.workspace = true
textkit.workspace = true
[dev-dependencies]
serde_json.workspace = true
[lints]
workspace = true
+179
View File
@@ -0,0 +1,179 @@
//! Advanced SubStation Alpha, which is a spreadsheet pretending to be a
//! subtitle file.
//!
//! Events are rows under a `Format:` line that names the columns, so the
//! column order has to be read rather than assumed — a file written by one
//! tool puts the text last after nine fields, and one written by another puts
//! it last after eight.
use std::sync::LazyLock;
use regex::Regex;
use crate::cue::Cue;
use crate::text;
/// An override block: `{\an8}`, `{\pos(120,400)}`, `{\i1}`.
static OVERRIDE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{[^}]*\}").expect("valid"));
/// A drawing command block, which is a shape rather than speech.
static DRAWING: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\{[^}]*\\p[1-9]").expect("valid"));
/// Parse an ASS or SSA file.
pub fn parse(content: &str) -> Vec<Cue> {
let mut columns: Option<Vec<String>> = None;
let mut in_events = false;
let mut cues = Vec::new();
for line in content.split('\n') {
let line = line.trim_matches(textkit::is_space);
if line.starts_with('[') {
in_events = line.eq_ignore_ascii_case("[events]");
continue;
}
if !in_events {
continue;
}
if let Some(format) = line.strip_prefix("Format:") {
columns = Some(
format
.split(',')
.map(|name| name.trim_matches(textkit::is_space).to_lowercase())
.collect(),
);
continue;
}
let Some(event) = line.strip_prefix("Dialogue:") else {
continue;
};
let Some(columns) = columns.as_ref() else {
continue;
};
if let Some(cue) = dialogue(event, columns) {
cues.push(cue);
}
}
cues
}
fn dialogue(event: &str, columns: &[String]) -> Option<Cue> {
let start_at = columns.iter().position(|name| name == "start")?;
let end_at = columns.iter().position(|name| name == "end")?;
let text_at = columns.iter().position(|name| name == "text")?;
// The text field is last and may itself contain commas, so the row is
// split only as many times as there are columns before it.
let mut fields: Vec<&str> = event.splitn(columns.len(), ',').collect();
if fields.len() < columns.len() {
return None;
}
let text = if text_at == columns.len() - 1 {
fields.pop().unwrap_or("")
} else {
fields.get(text_at).copied().unwrap_or("")
};
let start = timestamp(fields.get(start_at)?.trim_matches(textkit::is_space));
let end = timestamp(fields.get(end_at)?.trim_matches(textkit::is_space));
if !start.is_finite() || !end.is_finite() {
return None;
}
// A drawing command is a shape, not speech, and reads as a string of
// coordinates if it is kept.
if DRAWING.is_match(text) {
return None;
}
let text = OVERRIDE.replace_all(text, "");
let text = text
.replace("\\N", " ")
.replace("\\n", " ")
.replace("\\h", " ");
let text = text::clean(&text);
(!text.is_empty()).then(|| Cue::new(start, end, text))
}
/// Seconds from an `H:MM:SS.cc` timestamp.
pub fn timestamp(text: &str) -> f64 {
let parts: Vec<&str> = text.split(':').collect();
match parts.as_slice() {
[hours, minutes, seconds] => {
crate::jsnum::number(hours) * 3600.0
+ crate::jsnum::number(minutes) * 60.0
+ crate::jsnum::parse_float(seconds)
}
[minutes, seconds] => {
crate::jsnum::number(minutes) * 60.0 + crate::jsnum::parse_float(seconds)
}
_ => f64::NAN,
}
}
#[cfg(test)]
mod tests {
use super::*;
const FILE: &str = "[Script Info]\n\
Title: A Recording\n\
\n\
[V4+ Styles]\n\
Format: Name, Fontname\n\
Style: Default,Arial\n\
\n\
[Events]\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,The mill stood at the northern end.\n\
Dialogue: 0,0:00:03.00,0:00:05.50,Default,,0,0,0,,{\\an8}Its wheel was undershot,\\Nwhich was wasteful.\n";
#[test]
fn events_become_cues() {
let cues = parse(FILE);
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.");
}
#[test]
fn override_blocks_and_line_breaks_come_out() {
let cues = parse(FILE);
assert_eq!(cues[1].text, "Its wheel was undershot, which was wasteful.");
}
#[test]
fn a_comma_in_the_text_is_not_a_column_break() {
let cues = parse(FILE);
assert!(cues[1].text.contains(','), "{:?}", cues[1].text);
}
#[test]
fn a_drawing_command_is_not_speech() {
let file = FILE.to_string()
+ "Dialogue: 0,0:00:06.00,0:00:07.00,Default,,0,0,0,,{\\p1}m 0 0 l 100 0 100 100\n";
assert_eq!(parse(&file).len(), 2);
}
#[test]
fn a_row_outside_the_events_section_is_ignored() {
let file = "[Script Info]\nDialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,no\n";
assert!(parse(file).is_empty());
}
#[test]
fn a_column_order_of_its_own_is_honoured() {
let file = "[Events]\n\
Format: Start, End, Text\n\
Dialogue: 0:00:01.00,0:00:02.00,Reordered, with a comma\n";
let cues = parse(file);
assert_eq!(cues.len(), 1);
assert_eq!(cues[0].text, "Reordered, with a comma");
}
#[test]
fn a_timestamp_is_read_in_seconds() {
assert_eq!(timestamp("0:00:01.50"), 1.5);
assert_eq!(timestamp("1:02:03.04"), 3723.04);
assert!(timestamp("rubbish").is_nan());
}
}
+82
View File
@@ -0,0 +1,82 @@
//! One line of a transcript, and when it was said.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// A subtitle cue: a span of time and what was said in it.
///
/// Deliberately smaller than a chunk. A cue is what a subtitle file holds —
/// a few seconds, often half a sentence — and turning cues into passages
/// worth retrieving is a later stage's job.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Cue {
/// Record version.
#[serde(default = "version")]
pub v: u32,
/// Seconds from the start of the recording.
#[serde(with = "corpus_core::finite")]
#[schemars(with = "f64")]
pub start: f64,
/// Seconds from the start of the recording.
#[serde(with = "corpus_core::finite")]
#[schemars(with = "f64")]
pub end: f64,
/// What was said, with the markup taken out.
pub text: String,
}
fn version() -> u32 {
corpus_core::V
}
impl Cue {
/// A cue.
pub fn new(start: f64, end: f64, text: impl Into<String>) -> Self {
Self {
v: corpus_core::V,
start,
end,
text: text.into(),
}
}
/// How long the cue lasts. Negative if the file says it ends before it
/// starts, which some do.
pub fn seconds(&self) -> f64 {
self.end - self.start
}
}
impl corpus_core::Record for Cue {
fn version(&self) -> u32 {
self.v
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_field_is_emitted() {
let cue = Cue::new(12.5, 15.0, "the mill stood at the northern end");
assert_eq!(
serde_json::to_string(&cue).unwrap(),
r#"{"v":1,"start":12.5,"end":15.0,"text":"the mill stood at the northern end"}"#
);
}
#[test]
fn a_cue_reads_back_identical() {
let cue = Cue::new(0.0, 1.0 / 3.0, "x");
let text = serde_json::to_string(&cue).unwrap();
let back: Cue = serde_json::from_str(&text).unwrap();
assert_eq!(back.end.to_bits(), cue.end.to_bits());
}
#[test]
fn a_time_that_json_cannot_carry_is_refused() {
let cue = Cue::new(f64::NAN, 1.0, "x");
assert!(serde_json::to_string(&cue).is_err());
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Collapsing the repetition automatic captioning produces.
use crate::cue::Cue;
/// Merge consecutive cues whose text is identical, keeping the earliest start
/// and the latest end.
///
/// Automatic captioning emits a rolling window: the same line appears in cue
/// after cue as the window slides, so a ten-minute stretch of speech can
/// arrive as three times as many cues as there are sentences. Indexed as they
/// come, every one of those is a separate hit for the same words at almost
/// the same timestamp, which buries everything else in a result list.
pub fn merge_repeats(cues: &[Cue]) -> Vec<Cue> {
let mut merged: Vec<Cue> = Vec::with_capacity(cues.len());
for cue in cues {
match merged.last_mut() {
Some(previous) if previous.text == cue.text => {
previous.end = previous.end.max(cue.end);
}
_ => merged.push(cue.clone()),
}
}
merged
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_repeated_line_becomes_one_cue_spanning_both() {
let cues = [
Cue::new(0.0, 2.0, "the mill stood"),
Cue::new(1.0, 3.0, "the mill stood"),
Cue::new(3.0, 4.0, "at the northern end"),
];
let merged = merge_repeats(&cues);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].start, 0.0);
assert_eq!(merged[0].end, 3.0);
assert_eq!(merged[1].text, "at the northern end");
}
#[test]
fn a_line_that_comes_back_later_is_kept_separately() {
let cues = [
Cue::new(0.0, 1.0, "yes"),
Cue::new(1.0, 2.0, "no"),
Cue::new(2.0, 3.0, "yes"),
];
assert_eq!(merge_repeats(&cues).len(), 3);
}
#[test]
fn an_end_that_goes_backwards_does_not_shorten_the_merged_cue() {
let cues = [Cue::new(0.0, 9.0, "x"), Cue::new(1.0, 2.0, "x")];
let merged = merge_repeats(&cues);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].end, 9.0);
}
#[test]
fn nothing_in_nothing_out() {
assert!(merge_repeats(&[]).is_empty());
}
}
+176
View File
@@ -0,0 +1,176 @@
//! Reading numbers the way the JavaScript this was ported from reads them.
//!
//! The timestamp parser it came from calls `Number()` on the hours and the
//! minutes and `parseFloat()` on the seconds. The two disagree about almost
//! everything an ill-formed timestamp can contain — `Number("")` is zero
//! where `parseFloat("")` is not a number, `Number("1a")` is not a number
//! where `parseFloat("1a")` is one — and a subtitle file that has been cut,
//! concatenated or hand-edited produces exactly those. Reproducing both is
//! what makes a malformed file drop the same cues here as there.
/// What JavaScript's `Number(text)` gives.
///
/// Whitespace is trimmed, an empty string is zero, the radix prefixes are
/// honoured, and anything else that is not a complete numeric literal is not
/// a number. Note that `Number` accepts `Infinity` but not `NaN`, and neither
/// of the spellings Rust's own parser accepts for them.
pub fn number(text: &str) -> f64 {
let text = text.trim_matches(is_js_space);
if text.is_empty() {
return 0.0;
}
if let Some(value) = radix_literal(text) {
return value;
}
let body = text.strip_prefix(['+', '-']).unwrap_or(text);
// Rust's parser accepts spellings JavaScript's does not.
if body.eq_ignore_ascii_case("inf")
|| body.eq_ignore_ascii_case("nan")
|| body.eq_ignore_ascii_case("infinity")
{
return if body.eq_ignore_ascii_case("infinity") {
match text.starts_with('-') {
true => f64::NEG_INFINITY,
false => f64::INFINITY,
}
} else {
f64::NAN
};
}
text.parse().unwrap_or(f64::NAN)
}
/// What JavaScript's `parseFloat(text)` gives: the longest decimal number at
/// the front, ignoring whatever follows, and not a number when there is none.
pub fn parse_float(text: &str) -> f64 {
let text = text.trim_start_matches(is_js_space);
let prefix = decimal_prefix(text);
if prefix.is_empty() {
return f64::NAN;
}
prefix.parse().unwrap_or(f64::NAN)
}
/// The longest leading run that is a decimal number, by JavaScript's grammar.
fn decimal_prefix(text: &str) -> &str {
let bytes = text.as_bytes();
let mut at = 0;
if matches!(bytes.first(), Some(b'+' | b'-')) {
at = 1;
}
if text[at..].starts_with("Infinity") {
return &text[..at + "Infinity".len()];
}
let mut end = at;
let digits = |from: usize| {
let mut to = from;
while to < bytes.len() && bytes[to].is_ascii_digit() {
to += 1;
}
to
};
end = digits(end);
if end < bytes.len() && bytes[end] == b'.' {
end = digits(end + 1);
}
// A run with no digits at all on either side of the point is not a
// number: `.` and `+.` are nothing, `.5` and `5.` are numbers.
if !text[at..end].chars().any(|c| c.is_ascii_digit()) {
return "";
}
if end < bytes.len() && (bytes[end] | 0x20) == b'e' {
let mut exponent = end + 1;
if matches!(bytes.get(exponent), Some(b'+' | b'-')) {
exponent += 1;
}
let after = digits(exponent);
if after > exponent {
end = after;
}
}
&text[..end]
}
fn radix_literal(text: &str) -> Option<f64> {
let (radix, digits) = match text.get(..2)?.to_ascii_lowercase().as_str() {
"0x" => (16, &text[2..]),
"0o" => (8, &text[2..]),
"0b" => (2, &text[2..]),
_ => return None,
};
if digits.is_empty() {
return Some(f64::NAN);
}
Some(match u64::from_str_radix(digits, radix) {
Ok(value) => value as f64,
Err(_) => f64::NAN,
})
}
/// JavaScript's whitespace for the purpose of trimming a numeric string:
/// Unicode whitespace plus the byte-order mark.
fn is_js_space(c: char) -> bool {
c.is_whitespace() || c == '\u{feff}'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn number_reads_ordinary_numbers() {
assert_eq!(number("12"), 12.0);
assert_eq!(number(" 07 "), 7.0);
assert_eq!(number("1.5"), 1.5);
assert_eq!(number("1e2"), 100.0);
assert_eq!(number("-3"), -3.0);
}
#[test]
fn number_of_nothing_is_zero() {
assert_eq!(number(""), 0.0);
assert_eq!(number(" "), 0.0);
}
#[test]
fn number_refuses_a_partial_literal() {
assert!(number("1a").is_nan());
assert!(number("12:").is_nan());
assert!(number("--1").is_nan());
}
#[test]
fn number_does_not_accept_the_rust_spellings() {
assert!(number("inf").is_nan());
assert!(number("NaN").is_nan());
assert_eq!(number("Infinity"), f64::INFINITY);
assert_eq!(number("-Infinity"), f64::NEG_INFINITY);
}
#[test]
fn number_honours_the_radix_prefixes() {
assert_eq!(number("0x10"), 16.0);
assert_eq!(number("0b101"), 5.0);
assert_eq!(number("0o17"), 15.0);
}
#[test]
fn parse_float_takes_the_longest_prefix() {
assert_eq!(parse_float("23.456"), 23.456);
assert_eq!(parse_float("23.456abc"), 23.456);
assert_eq!(parse_float(" 1.5xyz"), 1.5);
assert_eq!(parse_float("5."), 5.0);
assert_eq!(parse_float(".5"), 0.5);
assert_eq!(parse_float("1e3x"), 1000.0);
assert_eq!(parse_float("1e"), 1.0);
}
#[test]
fn parse_float_of_nothing_is_not_a_number() {
assert!(parse_float("").is_nan());
assert!(parse_float("abc").is_nan());
assert!(parse_float(".").is_nan());
assert!(parse_float("+").is_nan());
}
}
+114
View File
@@ -0,0 +1,114 @@
//! The cue model everything timed shares, and the subtitle formats it is
//! read from.
//!
//! Three formats go in — SubRip, WebVTT and SubStation Alpha — and one cue
//! stream comes out. Everything downstream of this works on cues and never
//! has to know which of the three a recording arrived with.
//!
//! The SubRip reader is a port, quirks kept, of the parser a podcast archive
//! was indexed with; the other two are new, and are written to the same
//! shape. Where the port deliberately differs, `parity/KNOWN.md` says so.
pub mod ass;
pub mod cue;
pub mod dedup;
pub mod jsnum;
pub mod srt;
pub mod text;
pub use cue::Cue;
pub use dedup::merge_repeats;
/// Which subtitle format a file is written in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
/// SubRip.
SubRip,
/// WebVTT, which this reads with the SubRip reader.
WebVtt,
/// SubStation Alpha, advanced or otherwise.
SubStationAlpha,
}
impl Format {
/// What this format is called.
pub fn name(self) -> &'static str {
match self {
Format::SubRip => "srt",
Format::WebVtt => "vtt",
Format::SubStationAlpha => "ass",
}
}
}
/// Work out which format a file is, from its content rather than its name.
///
/// A subtitle file arrives named for the format someone expected it to be in.
/// The content says what it actually is, and the three are easy to tell
/// apart: WebVTT announces itself, SubStation Alpha has sections in square
/// brackets, and SubRip has neither.
pub fn detect(content: &str) -> Format {
let head = content.trim_start_matches(|c: char| textkit::is_space(c) || c == '\u{feff}');
if head.starts_with("WEBVTT") {
return Format::WebVtt;
}
for line in head.split('\n').take(40) {
let line = line.trim_matches(textkit::is_space);
if line.starts_with("[Script Info]") || line.eq_ignore_ascii_case("[events]") {
return Format::SubStationAlpha;
}
if line.starts_with("Dialogue:") || line.starts_with("ScriptType:") {
return Format::SubStationAlpha;
}
}
Format::SubRip
}
/// Read a subtitle file of any supported format into cues.
pub fn parse(content: &str) -> Vec<Cue> {
parse_as(content, detect(content))
}
/// Read a subtitle file that is known to be in `format`.
pub fn parse_as(content: &str, format: Format) -> Vec<Cue> {
match format {
Format::SubRip | Format::WebVtt => srt::parse(content),
Format::SubStationAlpha => ass::parse(content),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_file_is_recognised_by_what_is_in_it() {
assert_eq!(
detect("WEBVTT\n\n00:00.000 --> 00:01.000\nx"),
Format::WebVtt
);
assert_eq!(detect("\u{feff}WEBVTT\n"), Format::WebVtt);
assert_eq!(detect("[Script Info]\nTitle: x\n"), Format::SubStationAlpha);
assert_eq!(
detect("[Events]\nFormat: Start, End, Text\n"),
Format::SubStationAlpha
);
assert_eq!(
detect("1\n00:00:01,000 --> 00:00:02,000\nx\n"),
Format::SubRip
);
assert_eq!(detect(""), Format::SubRip);
}
#[test]
fn all_three_formats_give_the_same_cue() {
let srt = "1\n00:00:01,000 --> 00:00:03,000\nThe mill stood.\n";
let vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nThe mill stood.\n";
let ass = "[Events]\nFormat: Start, End, Text\n\
Dialogue: 0:00:01.00,0:00:03.00,The mill stood.\n";
let expected = vec![Cue::new(1.0, 3.0, "The mill stood.")];
assert_eq!(parse(srt), expected);
assert_eq!(parse(vtt), expected);
assert_eq!(parse(ass), expected);
}
}
+138
View File
@@ -0,0 +1,138 @@
//! 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);
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Cleaning the markup out of a cue's text.
use std::sync::LazyLock;
use regex::Regex;
/// Styling and speaker tags: `<i>`, `<v Speaker>`, `<c.colour>`.
static TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").expect("a valid pattern"));
/// Bracketed sound cues: `[Music]`, `[Applause]`, `[inaudible]`.
///
/// Not speech, and a search that matches them matches every recording with a
/// theme tune.
static BRACKETED: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[.*?\]").expect("a valid pattern"));
/// Strip markup from a cue's text.
pub fn clean(text: &str) -> String {
let text = TAG.replace_all(text, "");
let text = BRACKETED.replace_all(&text, "");
text.trim_matches(is_js_space).to_string()
}
/// What JavaScript's `String.trim` removes: Unicode whitespace and the
/// byte-order mark.
fn is_js_space(c: char) -> bool {
c.is_whitespace() || c == '\u{feff}'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn styling_tags_go() {
assert_eq!(clean("<i>The mill</i> stood"), "The mill stood");
assert_eq!(clean("<v Speaker>Hello"), "Hello");
}
#[test]
fn bracketed_sound_cues_go() {
assert_eq!(clean("[Music] The mill stood [Applause]"), "The mill stood");
assert_eq!(clean("[Music]"), "");
}
#[test]
fn the_bracket_match_is_not_greedy() {
assert_eq!(clean("[a] kept [b]"), "kept");
}
#[test]
fn a_stray_pair_of_angle_brackets_takes_what_is_between_them() {
// Faithful to the original, which uses the same pattern. A spoken
// line with a less-than and a greater-than in it is vanishingly rare
// next to the styling tags this exists to remove.
assert_eq!(clean("a < b and c > d"), "a d");
}
#[test]
fn a_lone_angle_bracket_is_left_alone() {
assert_eq!(clean("a < b"), "a < b");
}
}