use crate::{
analysis::{
events::text_analysis::TextAnalysis,
linting::{IssueCategory, IssueSeverity, LintIssue, LintRule},
ScriptAnalysis,
},
parser::Section,
utils::parse_ass_time,
};
use alloc::{format, string::ToString, vec::Vec};
pub struct AccessibilityRule;
impl LintRule for AccessibilityRule {
fn id(&self) -> &'static str {
"accessibility"
}
fn name(&self) -> &'static str {
"Accessibility"
}
fn description(&self) -> &'static str {
"Detects potential accessibility issues"
}
fn default_severity(&self) -> IssueSeverity {
IssueSeverity::Hint
}
fn category(&self) -> IssueCategory {
IssueCategory::Accessibility
}
fn check_script(&self, analysis: &ScriptAnalysis) -> Vec<LintIssue> {
let mut issues = Vec::new();
if let Some(Section::Events(events)) = analysis
.script()
.sections()
.iter()
.find(|s| matches!(s, Section::Events(_)))
{
for event in events {
self.check_event_duration(&mut issues, event);
self.check_reading_speed(&mut issues, event);
self.check_text_length(&mut issues, event);
}
}
issues
}
}
impl AccessibilityRule {
fn check_event_duration(&self, issues: &mut Vec<LintIssue>, event: &crate::parser::Event) {
if let (Ok(start), Ok(end)) = (parse_ass_time(event.start), parse_ass_time(event.end)) {
if end >= start {
let duration_ms = end - start;
if duration_ms < 500 {
let issue = LintIssue::new(
self.default_severity(),
IssueCategory::Accessibility,
self.id(),
format!("Very short event duration: {duration_ms}ms"),
)
.with_description("Short durations may be difficult to read".to_string())
.with_suggested_fix(
"Consider extending duration to at least 500ms for readability".to_string(),
);
issues.push(issue);
}
}
}
}
fn check_reading_speed(&self, issues: &mut Vec<LintIssue>, event: &crate::parser::Event) {
if let (Ok(start), Ok(end)) = (parse_ass_time(event.start), parse_ass_time(event.end)) {
if end >= start {
let duration_centiseconds = end - start;
if let Ok(analysis) = TextAnalysis::analyze(event.text) {
let clean_text_length = analysis.char_count();
if clean_text_length > 0 && duration_centiseconds > 0 {
let duration_seconds = f64::from(duration_centiseconds) / 100.0;
let safe_length = u32::try_from(clean_text_length)
.unwrap_or(10_000)
.min(10_000);
let chars_per_second = f64::from(safe_length) / duration_seconds;
if chars_per_second > 20.0 {
let issue = LintIssue::new(
self.default_severity(),
IssueCategory::Accessibility,
self.id(),
format!(
"Fast reading speed: {chars_per_second:.1} characters/second"
),
)
.with_description(
"Fast reading speeds may be difficult for some users".to_string(),
)
.with_suggested_fix(
"Consider extending duration or reducing text length".to_string(),
);
issues.push(issue);
}
}
}
}
}
}
fn check_text_length(&self, issues: &mut Vec<LintIssue>, event: &crate::parser::Event) {
if let Ok(analysis) = TextAnalysis::analyze(event.text) {
let clean_text_length = analysis.char_count();
if clean_text_length > 200 {
let issue = LintIssue::new(
self.default_severity(),
IssueCategory::Accessibility,
self.id(),
format!("Very long text: {clean_text_length} characters"),
)
.with_description("Long text blocks may be overwhelming for some users".to_string())
.with_suggested_fix("Consider splitting into multiple shorter events".to_string());
issues.push(issue);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rule_metadata_correct() {
let rule = AccessibilityRule;
assert_eq!(rule.id(), "accessibility");
assert_eq!(rule.name(), "Accessibility");
assert_eq!(rule.description(), "Detects potential accessibility issues");
assert_eq!(rule.default_severity(), IssueSeverity::Hint);
assert_eq!(rule.category(), IssueCategory::Accessibility);
}
#[test]
fn empty_script_no_issues() {
let script_text = "[Script Info]\nTitle: Test";
let script = crate::parser::Script::parse(script_text).unwrap();
let analysis = ScriptAnalysis::analyze(&script).unwrap();
let rule = AccessibilityRule;
let issues = rule.check_script(&analysis);
assert!(issues.is_empty());
}
#[test]
fn normal_duration_no_issues() {
let script_text = r"[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Normal duration text";
let script = crate::parser::Script::parse(script_text).unwrap();
let analysis = ScriptAnalysis::analyze(&script).unwrap();
let rule = AccessibilityRule;
let issues = rule.check_script(&analysis);
assert!(issues.is_empty());
}
#[test]
fn short_duration_detected() {
let script_text = r"[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:00.30,Default,,0,0,0,,Too fast!";
let script = crate::parser::Script::parse(script_text).unwrap();
let rule = AccessibilityRule;
let analysis = ScriptAnalysis::analyze(&script).unwrap();
let issues = rule.check_script(&analysis);
assert!(!issues.is_empty());
assert!(issues
.iter()
.any(|issue| issue.message().contains("short event duration")));
}
#[test]
fn fast_reading_speed_detected() {
let long_text = "This is a very long text that would require fast reading speed to comprehend in the given short duration which may be difficult for some users";
let script_text = format!(
r"[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:02.00,Default,,0,0,0,,{long_text}"
);
let script = crate::parser::Script::parse(&script_text).unwrap();
let rule = AccessibilityRule;
let analysis = ScriptAnalysis::analyze(&script).unwrap();
let issues = rule.check_script(&analysis);
assert!(issues
.iter()
.any(|issue| issue.message().contains("reading speed")));
}
#[test]
fn text_analysis_excludes_tags() {
use crate::analysis::events::text_analysis::TextAnalysis;
let analysis1 = TextAnalysis::analyze("Hello world").unwrap();
assert_eq!(analysis1.char_count(), 11);
let analysis2 = TextAnalysis::analyze("Hello {\\i1}world{\\i0}").unwrap();
assert_eq!(analysis2.char_count(), 11);
let analysis3 = TextAnalysis::analyze("{\\b1}Bold{\\b0} text").unwrap();
assert_eq!(analysis3.char_count(), 9);
let analysis4 = TextAnalysis::analyze("").unwrap();
assert_eq!(analysis4.char_count(), 0);
}
#[test]
fn long_text_detected() {
let long_text = "a".repeat(250);
let script_text = format!(
r"[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,,{long_text}"
);
let script = crate::parser::Script::parse(&script_text).unwrap();
let rule = AccessibilityRule;
let analysis = ScriptAnalysis::analyze(&script).unwrap();
let issues = rule.check_script(&analysis);
assert!(issues
.iter()
.any(|issue| issue.message().contains("Very long text")));
}
#[test]
fn no_events_section_no_issues() {
let script_text = r"[Script Info]
Title: Test
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF&,&H000000FF&,&H00000000&,&H00000000&,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1";
let script = crate::parser::Script::parse(script_text).unwrap();
let rule = AccessibilityRule;
let analysis = ScriptAnalysis::analyze(&script).unwrap();
let issues = rule.check_script(&analysis);
assert!(issues.is_empty());
}
}