use crate::alignment::CaptionBlock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Audience {
Children,
Adult,
Broadcast,
Custom { max_cps: u8 },
}
impl Audience {
pub fn max_cps(&self) -> f64 {
match self {
Audience::Children => 10.0,
Audience::Adult => 17.0,
Audience::Broadcast => 20.0,
Audience::Custom { max_cps } => f64::from(*max_cps),
}
}
}
#[derive(Debug, Clone)]
pub struct StyleGuide {
pub max_chars_per_line: usize,
pub max_lines: usize,
pub min_duration_ms: u64,
pub max_duration_ms: u64,
pub min_gap_ms: u64,
pub audience: Audience,
pub require_capitalisation: bool,
pub require_terminal_punctuation: bool,
pub max_short_line_chars: Option<usize>,
}
impl StyleGuide {
pub fn broadcast() -> Self {
Self {
max_chars_per_line: 42,
max_lines: 2,
min_duration_ms: 833, max_duration_ms: 7_000,
min_gap_ms: 80,
audience: Audience::Broadcast,
require_capitalisation: true,
require_terminal_punctuation: false,
max_short_line_chars: Some(40),
}
}
pub fn streaming() -> Self {
Self {
max_chars_per_line: 42,
max_lines: 2,
min_duration_ms: 833,
max_duration_ms: 7_000,
min_gap_ms: 83, audience: Audience::Adult,
require_capitalisation: true,
require_terminal_punctuation: false,
max_short_line_chars: None,
}
}
pub fn children() -> Self {
Self {
max_chars_per_line: 32,
max_lines: 2,
min_duration_ms: 1_200,
max_duration_ms: 5_500,
min_gap_ms: 120,
audience: Audience::Children,
require_capitalisation: true,
require_terminal_punctuation: true,
max_short_line_chars: Some(28),
}
}
pub fn check_block(&self, block: &CaptionBlock) -> Vec<StyleViolation> {
let mut violations = Vec::new();
if block.lines.len() > self.max_lines {
violations.push(StyleViolation::TooManyLines {
block_id: block.id,
actual: block.lines.len(),
max: self.max_lines,
});
}
for (line_idx, line) in block.lines.iter().enumerate() {
let chars = line.chars().count();
if chars > self.max_chars_per_line {
violations.push(StyleViolation::LineTooLong {
block_id: block.id,
line_index: line_idx,
actual: chars,
max: self.max_chars_per_line,
});
}
}
let duration_ms = block.duration_ms();
if duration_ms < self.min_duration_ms {
violations.push(StyleViolation::DurationTooShort {
block_id: block.id,
actual_ms: duration_ms,
min_ms: self.min_duration_ms,
});
}
if duration_ms > self.max_duration_ms {
violations.push(StyleViolation::DurationTooLong {
block_id: block.id,
actual_ms: duration_ms,
max_ms: self.max_duration_ms,
});
}
let total_chars: usize = block.lines.iter().map(|l| l.chars().count()).sum();
if duration_ms > 0 {
let cps = total_chars as f64 / (duration_ms as f64 / 1000.0);
let max_cps = self.audience.max_cps();
if cps > max_cps {
violations.push(StyleViolation::ReadingSpeedExceeded {
block_id: block.id,
actual_cps: cps,
max_cps,
});
}
}
if self.require_capitalisation {
if let Some(first_line) = block.lines.first() {
if let Some(first_char) = first_line.chars().next() {
if first_char.is_alphabetic() && !first_char.is_uppercase() {
violations
.push(StyleViolation::MissingCapitalisation { block_id: block.id });
}
}
}
}
if self.require_terminal_punctuation {
if let Some(last_line) = block.lines.last() {
let last_char = last_line.chars().next_back();
match last_char {
Some('.' | '!' | '?') => {}
_ => {
violations.push(StyleViolation::MissingTerminalPunctuation {
block_id: block.id,
});
}
}
}
}
if let Some(max_short) = self.max_short_line_chars {
if block.lines.len() == 2 {
let lens: Vec<usize> = block.lines.iter().map(|l| l.chars().count()).collect();
let shorter = lens[0].min(lens[1]);
if shorter > max_short {
violations.push(StyleViolation::ShortLineImbalance {
block_id: block.id,
shorter_len: shorter,
max_short,
});
}
}
}
violations
}
pub fn check_track(&self, blocks: &[CaptionBlock]) -> Vec<StyleViolation> {
let mut violations = Vec::new();
for block in blocks {
violations.extend(self.check_block(block));
}
for pair in blocks.windows(2) {
let a = &pair[0];
let b = &pair[1];
if b.start_ms > a.end_ms {
let gap_ms = b.start_ms - a.end_ms;
if gap_ms < self.min_gap_ms {
violations.push(StyleViolation::GapTooShort {
before_id: a.id,
after_id: b.id,
actual_ms: gap_ms,
min_ms: self.min_gap_ms,
});
}
} else if b.start_ms < a.end_ms {
violations.push(StyleViolation::CaptionOverlap {
block_id_a: a.id,
block_id_b: b.id,
});
}
}
violations
}
pub fn compliance_score(&self, blocks: &[CaptionBlock]) -> f64 {
if blocks.is_empty() {
return 1.0;
}
let violations = self.check_track(blocks).len();
let max_checks = blocks.len() * 6 + blocks.len().saturating_sub(1);
if max_checks == 0 {
return 1.0;
}
let ratio = violations as f64 / max_checks as f64;
(1.0 - ratio).max(0.0)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum StyleViolation {
LineTooLong {
block_id: u32,
line_index: usize,
actual: usize,
max: usize,
},
TooManyLines {
block_id: u32,
actual: usize,
max: usize,
},
DurationTooShort {
block_id: u32,
actual_ms: u64,
min_ms: u64,
},
DurationTooLong {
block_id: u32,
actual_ms: u64,
max_ms: u64,
},
ReadingSpeedExceeded {
block_id: u32,
actual_cps: f64,
max_cps: f64,
},
MissingCapitalisation { block_id: u32 },
MissingTerminalPunctuation { block_id: u32 },
CaptionOverlap { block_id_a: u32, block_id_b: u32 },
GapTooShort {
before_id: u32,
after_id: u32,
actual_ms: u64,
min_ms: u64,
},
ShortLineImbalance {
block_id: u32,
shorter_len: usize,
max_short: usize,
},
}
impl StyleViolation {
pub fn block_id(&self) -> u32 {
match self {
StyleViolation::LineTooLong { block_id, .. } => *block_id,
StyleViolation::TooManyLines { block_id, .. } => *block_id,
StyleViolation::DurationTooShort { block_id, .. } => *block_id,
StyleViolation::DurationTooLong { block_id, .. } => *block_id,
StyleViolation::ReadingSpeedExceeded { block_id, .. } => *block_id,
StyleViolation::MissingCapitalisation { block_id } => *block_id,
StyleViolation::MissingTerminalPunctuation { block_id } => *block_id,
StyleViolation::CaptionOverlap { block_id_a, .. } => *block_id_a,
StyleViolation::GapTooShort { before_id, .. } => *before_id,
StyleViolation::ShortLineImbalance { block_id, .. } => *block_id,
}
}
pub fn description(&self) -> String {
match self {
StyleViolation::LineTooLong { block_id, line_index, actual, max } =>
format!("Block {block_id} line {line_index}: {actual} chars > max {max}"),
StyleViolation::TooManyLines { block_id, actual, max } =>
format!("Block {block_id}: {actual} lines > max {max}"),
StyleViolation::DurationTooShort { block_id, actual_ms, min_ms } =>
format!("Block {block_id}: {actual_ms}ms < min {min_ms}ms"),
StyleViolation::DurationTooLong { block_id, actual_ms, max_ms } =>
format!("Block {block_id}: {actual_ms}ms > max {max_ms}ms"),
StyleViolation::ReadingSpeedExceeded { block_id, actual_cps, max_cps } =>
format!("Block {block_id}: {actual_cps:.1} CPS > max {max_cps:.1} CPS"),
StyleViolation::MissingCapitalisation { block_id } =>
format!("Block {block_id}: caption does not begin with a capital letter"),
StyleViolation::MissingTerminalPunctuation { block_id } =>
format!("Block {block_id}: caption does not end with terminal punctuation"),
StyleViolation::CaptionOverlap { block_id_a, block_id_b } =>
format!("Blocks {block_id_a} and {block_id_b} overlap in time"),
StyleViolation::GapTooShort { before_id, after_id, actual_ms, min_ms } =>
format!("Gap between blocks {before_id} and {after_id}: {actual_ms}ms < min {min_ms}ms"),
StyleViolation::ShortLineImbalance { block_id, shorter_len, max_short } =>
format!("Block {block_id}: shorter line has {shorter_len} chars > balance limit {max_short}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::alignment::CaptionPosition;
fn make_block(id: u32, start_ms: u64, end_ms: u64, lines: Vec<&str>) -> CaptionBlock {
CaptionBlock {
id,
start_ms,
end_ms,
lines: lines.into_iter().map(|s| s.to_string()).collect(),
speaker_id: None,
position: CaptionPosition::Bottom,
}
}
#[test]
fn valid_broadcast_block_no_violations() {
let guide = StyleGuide::broadcast();
let block = make_block(1, 0, 2000, vec!["Hello, world!"]);
assert!(guide.check_block(&block).is_empty());
}
#[test]
fn line_too_long_detected() {
let guide = StyleGuide::broadcast(); let long_line = "A".repeat(50);
let block = make_block(1, 0, 3000, vec![&long_line]);
let v = guide.check_block(&block);
assert!(v
.iter()
.any(|v| matches!(v, StyleViolation::LineTooLong { .. })));
}
#[test]
fn too_many_lines_detected() {
let guide = StyleGuide::broadcast(); let block = make_block(1, 0, 4000, vec!["Line one.", "Line two.", "Line three."]);
let v = guide.check_block(&block);
assert!(v
.iter()
.any(|v| matches!(v, StyleViolation::TooManyLines { .. })));
}
#[test]
fn duration_too_short_detected() {
let guide = StyleGuide::broadcast(); let block = make_block(1, 0, 200, vec!["Hi."]);
let v = guide.check_block(&block);
assert!(v
.iter()
.any(|v| matches!(v, StyleViolation::DurationTooShort { .. })));
}
#[test]
fn duration_too_long_detected() {
let guide = StyleGuide::broadcast(); let block = make_block(1, 0, 10_000, vec!["Hello."]);
let v = guide.check_block(&block);
assert!(v
.iter()
.any(|v| matches!(v, StyleViolation::DurationTooLong { .. })));
}
#[test]
fn reading_speed_exceeded_for_children() {
let guide = StyleGuide::children(); let long_text = "A".repeat(100);
let block = make_block(1, 0, 1000, vec![&long_text]);
let v = guide.check_block(&block);
assert!(v
.iter()
.any(|v| matches!(v, StyleViolation::ReadingSpeedExceeded { .. })));
}
#[test]
fn missing_capitalisation_detected() {
let guide = StyleGuide::broadcast();
let block = make_block(1, 0, 2000, vec!["hello world"]);
let v = guide.check_block(&block);
assert!(v
.iter()
.any(|v| matches!(v, StyleViolation::MissingCapitalisation { .. })));
}
#[test]
fn terminal_punctuation_required_by_children_guide() {
let guide = StyleGuide::children();
let block = make_block(1, 0, 2000, vec!["Hello world"]);
let v = guide.check_block(&block);
assert!(v
.iter()
.any(|v| matches!(v, StyleViolation::MissingTerminalPunctuation { .. })));
}
#[test]
fn gap_too_short_detected() {
let guide = StyleGuide::broadcast(); let blocks = vec![
make_block(1, 0, 1000, vec!["First."]),
make_block(2, 1010, 2000, vec!["Second."]), ];
let v = guide.check_track(&blocks);
assert!(v
.iter()
.any(|v| matches!(v, StyleViolation::GapTooShort { .. })));
}
#[test]
fn overlap_detected() {
let guide = StyleGuide::broadcast();
let blocks = vec![
make_block(1, 0, 1500, vec!["First."]),
make_block(2, 1000, 2000, vec!["Second."]), ];
let v = guide.check_track(&blocks);
assert!(v
.iter()
.any(|v| matches!(v, StyleViolation::CaptionOverlap { .. })));
}
#[test]
fn compliance_score_perfect() {
let guide = StyleGuide::broadcast();
let blocks = vec![
make_block(1, 0, 2000, vec!["Hello, world!"]),
make_block(2, 2100, 4000, vec!["This is a test."]),
];
let score = guide.compliance_score(&blocks);
assert_eq!(score, 1.0);
}
#[test]
fn compliance_score_partial() {
let guide = StyleGuide::broadcast();
let blocks = vec![
make_block(1, 0, 100, vec!["Hi."]),
make_block(2, 300, 2000, vec!["This is fine."]),
];
let score = guide.compliance_score(&blocks);
assert!(score < 1.0, "score should be < 1.0 due to violation");
assert!(score >= 0.0);
}
#[test]
fn audience_max_cps_ordering() {
assert!(Audience::Children.max_cps() < Audience::Adult.max_cps());
assert!(Audience::Adult.max_cps() < Audience::Broadcast.max_cps());
}
#[test]
fn custom_audience_respected() {
let guide = StyleGuide {
audience: Audience::Custom { max_cps: 5 },
max_chars_per_line: 80,
max_lines: 3,
min_duration_ms: 0,
max_duration_ms: 60_000,
min_gap_ms: 0,
require_capitalisation: false,
require_terminal_punctuation: false,
max_short_line_chars: None,
};
let block = make_block(1, 0, 1000, vec![&"A".repeat(50)]);
let v = guide.check_block(&block);
assert!(v
.iter()
.any(|v| matches!(v, StyleViolation::ReadingSpeedExceeded { .. })));
}
#[test]
fn violation_description_non_empty() {
let v = StyleViolation::LineTooLong {
block_id: 3,
line_index: 0,
actual: 50,
max: 42,
};
assert!(!v.description().is_empty());
assert!(v.description().contains("50"));
}
}