1use crate::{
8 analysis::{
9 events::text_analysis::TextAnalysis,
10 linting::{IssueCategory, IssueSeverity, LintIssue, LintRule},
11 ScriptAnalysis,
12 },
13 parser::Section,
14 utils::parse_ass_time,
15};
16use alloc::{format, string::ToString, vec::Vec};
17
18pub struct AccessibilityRule;
55
56impl LintRule for AccessibilityRule {
57 fn id(&self) -> &'static str {
58 "accessibility"
59 }
60
61 fn name(&self) -> &'static str {
62 "Accessibility"
63 }
64
65 fn description(&self) -> &'static str {
66 "Detects potential accessibility issues"
67 }
68
69 fn default_severity(&self) -> IssueSeverity {
70 IssueSeverity::Hint
71 }
72
73 fn category(&self) -> IssueCategory {
74 IssueCategory::Accessibility
75 }
76
77 fn check_script(&self, analysis: &ScriptAnalysis) -> Vec<LintIssue> {
78 let mut issues = Vec::new();
79
80 if let Some(Section::Events(events)) = analysis
81 .script()
82 .sections()
83 .iter()
84 .find(|s| matches!(s, Section::Events(_)))
85 {
86 for event in events {
87 self.check_event_duration(&mut issues, event);
88 self.check_reading_speed(&mut issues, event);
89 self.check_text_length(&mut issues, event);
90 }
91 }
92
93 issues
94 }
95}
96
97impl AccessibilityRule {
98 fn check_event_duration(&self, issues: &mut Vec<LintIssue>, event: &crate::parser::Event) {
100 if let (Ok(start), Ok(end)) = (parse_ass_time(event.start), parse_ass_time(event.end)) {
101 if end >= start {
102 let duration_ms = end - start;
103 if duration_ms < 500 {
104 let issue = LintIssue::new(
105 self.default_severity(),
106 IssueCategory::Accessibility,
107 self.id(),
108 format!("Very short event duration: {duration_ms}ms"),
109 )
110 .with_description("Short durations may be difficult to read".to_string())
111 .with_suggested_fix(
112 "Consider extending duration to at least 500ms for readability".to_string(),
113 );
114
115 issues.push(issue);
116 }
117 }
118 }
119 }
120
121 fn check_reading_speed(&self, issues: &mut Vec<LintIssue>, event: &crate::parser::Event) {
123 if let (Ok(start), Ok(end)) = (parse_ass_time(event.start), parse_ass_time(event.end)) {
124 if end >= start {
125 let duration_centiseconds = end - start;
126
127 if let Ok(analysis) = TextAnalysis::analyze(event.text) {
128 let clean_text_length = analysis.char_count();
129
130 if clean_text_length > 0 && duration_centiseconds > 0 {
131 let duration_seconds = f64::from(duration_centiseconds) / 100.0;
133 let safe_length = u32::try_from(clean_text_length)
134 .unwrap_or(10_000)
135 .min(10_000);
136 let chars_per_second = f64::from(safe_length) / duration_seconds;
137
138 if chars_per_second > 20.0 {
139 let issue = LintIssue::new(
140 self.default_severity(),
141 IssueCategory::Accessibility,
142 self.id(),
143 format!(
144 "Fast reading speed: {chars_per_second:.1} characters/second"
145 ),
146 )
147 .with_description(
148 "Fast reading speeds may be difficult for some users".to_string(),
149 )
150 .with_suggested_fix(
151 "Consider extending duration or reducing text length".to_string(),
152 );
153
154 issues.push(issue);
155 }
156 }
157 }
158 }
159 }
160 }
161
162 fn check_text_length(&self, issues: &mut Vec<LintIssue>, event: &crate::parser::Event) {
164 if let Ok(analysis) = TextAnalysis::analyze(event.text) {
165 let clean_text_length = analysis.char_count();
166
167 if clean_text_length > 200 {
168 let issue = LintIssue::new(
169 self.default_severity(),
170 IssueCategory::Accessibility,
171 self.id(),
172 format!("Very long text: {clean_text_length} characters"),
173 )
174 .with_description("Long text blocks may be overwhelming for some users".to_string())
175 .with_suggested_fix("Consider splitting into multiple shorter events".to_string());
176
177 issues.push(issue);
178 }
179 }
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn rule_metadata_correct() {
189 let rule = AccessibilityRule;
190 assert_eq!(rule.id(), "accessibility");
191 assert_eq!(rule.name(), "Accessibility");
192 assert_eq!(rule.description(), "Detects potential accessibility issues");
193 assert_eq!(rule.default_severity(), IssueSeverity::Hint);
194 assert_eq!(rule.category(), IssueCategory::Accessibility);
195 }
196
197 #[test]
198 fn empty_script_no_issues() {
199 let script_text = "[Script Info]\nTitle: Test";
200 let script = crate::parser::Script::parse(script_text).unwrap();
201 let analysis = ScriptAnalysis::analyze(&script).unwrap();
202
203 let rule = AccessibilityRule;
204 let issues = rule.check_script(&analysis);
205
206 assert!(issues.is_empty());
207 }
208
209 #[test]
210 fn normal_duration_no_issues() {
211 let script_text = r"[Events]
212Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
213Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Normal duration text";
214
215 let script = crate::parser::Script::parse(script_text).unwrap();
216 let analysis = ScriptAnalysis::analyze(&script).unwrap();
217 let rule = AccessibilityRule;
218 let issues = rule.check_script(&analysis);
219
220 assert!(issues.is_empty());
221 }
222
223 #[test]
224 fn short_duration_detected() {
225 let script_text = r"[Events]
226Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
227Dialogue: 0,0:00:00.00,0:00:00.30,Default,,0,0,0,,Too fast!";
228
229 let script = crate::parser::Script::parse(script_text).unwrap();
230 let rule = AccessibilityRule;
231 let analysis = ScriptAnalysis::analyze(&script).unwrap();
232 let issues = rule.check_script(&analysis);
233
234 assert!(!issues.is_empty());
235 assert!(issues
236 .iter()
237 .any(|issue| issue.message().contains("short event duration")));
238 }
239
240 #[test]
241 fn fast_reading_speed_detected() {
242 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";
243 let script_text = format!(
244 r"[Events]
245Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
246Dialogue: 0,0:00:00.00,0:00:02.00,Default,,0,0,0,,{long_text}"
247 );
248
249 let script = crate::parser::Script::parse(&script_text).unwrap();
250 let rule = AccessibilityRule;
251 let analysis = ScriptAnalysis::analyze(&script).unwrap();
252 let issues = rule.check_script(&analysis);
253
254 assert!(issues
255 .iter()
256 .any(|issue| issue.message().contains("reading speed")));
257 }
258
259 #[test]
260 fn text_analysis_excludes_tags() {
261 use crate::analysis::events::text_analysis::TextAnalysis;
262
263 let analysis1 = TextAnalysis::analyze("Hello world").unwrap();
264 assert_eq!(analysis1.char_count(), 11);
265
266 let analysis2 = TextAnalysis::analyze("Hello {\\i1}world{\\i0}").unwrap();
268 assert_eq!(analysis2.char_count(), 11);
269
270 let analysis3 = TextAnalysis::analyze("{\\b1}Bold{\\b0} text").unwrap();
272 assert_eq!(analysis3.char_count(), 9);
273
274 let analysis4 = TextAnalysis::analyze("").unwrap();
275 assert_eq!(analysis4.char_count(), 0);
276 }
277
278 #[test]
279 fn long_text_detected() {
280 let long_text = "a".repeat(250);
281 let script_text = format!(
282 r"[Events]
283Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
284Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,,{long_text}"
285 );
286
287 let script = crate::parser::Script::parse(&script_text).unwrap();
288 let rule = AccessibilityRule;
289 let analysis = ScriptAnalysis::analyze(&script).unwrap();
290 let issues = rule.check_script(&analysis);
291
292 assert!(issues
293 .iter()
294 .any(|issue| issue.message().contains("Very long text")));
295 }
296
297 #[test]
298 fn no_events_section_no_issues() {
299 let script_text = r"[Script Info]
300Title: Test
301
302[V4+ Styles]
303Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
304Style: Default,Arial,20,&H00FFFFFF&,&H000000FF&,&H00000000&,&H00000000&,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1";
305
306 let script = crate::parser::Script::parse(script_text).unwrap();
307 let rule = AccessibilityRule;
308 let analysis = ScriptAnalysis::analyze(&script).unwrap();
309 let issues = rule.check_script(&analysis);
310
311 assert!(issues.is_empty());
312 }
313}