1use std::sync::Arc;
17
18use crate::error::{DocError, Result};
19use crate::model::{Block, BlockKind, Document, Page};
20use crate::plugin::{FormatPlugin, ParseCapability, RawDoc, RawPage, RawTextItem};
21use crate::registry::Registry;
22
23pub const FORMATS: &[&str] = &["srt", "vtt", "ass", "ssa"];
25
26fn entity(name: &str) -> Option<char> {
28 match name {
29 "amp" => Some('&'),
30 "lt" => Some('<'),
31 "gt" => Some('>'),
32 "quot" => Some('"'),
33 "apos" => Some('\''),
34 "nbsp" => Some(' '),
35 _ => None,
36 }
37}
38
39pub fn strip_tags(text: &str) -> String {
41 let chars: Vec<char> = text.chars().collect();
42 let mut out = String::with_capacity(text.len());
43 let mut i = 0;
44 while i < chars.len() {
45 if chars[i] == '<' {
46 if let Some(off) = chars[i + 1..].iter().position(|c| *c == '>') {
47 i += off + 2;
48 continue;
49 }
50 }
51 out.push(chars[i]);
52 i += 1;
53 }
54 out
55}
56
57pub fn decode_entities(text: &str) -> String {
59 let chars: Vec<char> = text.chars().collect();
60 let mut out = String::with_capacity(text.len());
61 let mut i = 0;
62 while i < chars.len() {
63 if chars[i] == '&' {
64 if let Some(off) = chars[i + 1..].iter().position(|c| *c == ';') {
65 if off <= 9 {
66 let name: String = chars[i + 1..i + 1 + off].iter().collect();
67 if let Some(c) = entity(&name) {
68 out.push(c);
69 i += off + 2;
70 continue;
71 }
72 }
73 }
74 }
75 out.push(chars[i]);
76 i += 1;
77 }
78 out
79}
80
81pub fn clean_text(raw: &str) -> String {
83 let body = decode_entities(&strip_tags(raw));
84 body.split('\n')
85 .map(|line| line.trim())
86 .collect::<Vec<_>>()
87 .join("\n")
88 .trim()
89 .to_string()
90}
91
92pub fn strip_ass_overrides(text: &str) -> String {
94 let chars: Vec<char> = text.chars().collect();
95 let mut out = String::with_capacity(text.len());
96 let mut i = 0;
97 while i < chars.len() {
98 if chars[i] == '{' {
99 if let Some(off) = chars[i + 1..].iter().position(|c| *c == '}') {
100 i += off + 2;
101 continue;
102 }
103 }
104 out.push(chars[i]);
105 i += 1;
106 }
107 out
108}
109
110pub fn ass_text(raw: &str) -> String {
112 strip_ass_overrides(raw)
113 .replace("\\N", "\n")
114 .replace("\\n", "\n")
115 .replace("\\h", " ")
116}
117
118fn all_digits(s: &str) -> bool {
119 !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
120}
121
122pub fn parse_clock(spec: &str) -> Option<i64> {
126 let s = spec.trim();
127 if s.is_empty() {
128 return None;
129 }
130 let parts: Vec<&str> = s.split(':').collect();
131 if parts.len() != 2 && parts.len() != 3 {
132 return None;
133 }
134 let (head, tail) = parts.split_at(parts.len() - 1);
135 if !head.iter().all(|p| all_digits(p)) {
136 return None;
137 }
138 let tail = tail[0];
139 let sep = if tail.contains(',') {
140 ','
141 } else if tail.contains('.') {
142 '.'
143 } else {
144 return None;
145 };
146 let (sec, frac) = tail.split_once(sep)?;
147 if !all_digits(sec) || !all_digits(frac) {
148 return None;
149 }
150 let mut milli = frac.chars().take(3).collect::<String>();
151 while milli.len() < 3 {
152 milli.push('0');
153 }
154 let milli: i64 = milli.parse().ok()?;
155 let (hours, minutes) = if head.len() == 2 {
156 (head[0].parse::<i64>().ok()?, head[1].parse::<i64>().ok()?)
157 } else {
158 (0, head[0].parse::<i64>().ok()?)
159 };
160 let total = hours
161 .saturating_mul(3600)
162 .saturating_add(minutes.saturating_mul(60))
163 .saturating_add(sec.parse::<i64>().ok()?);
164 Some(total.saturating_mul(1000).saturating_add(milli))
165}
166
167pub fn format_clock(ms: i64) -> String {
169 let ms = if ms < 0 { 0 } else { ms };
170 let milli = ms % 1000;
171 let total = ms / 1000;
172 let second = total % 60;
173 let minutes_total = total / 60;
174 let minute = minutes_total % 60;
175 let hour = minutes_total / 60;
176 format!("{hour:02}:{minute:02}:{second:02},{milli:03}")
177}
178
179pub fn parse_timeline(line: &str) -> Option<(i64, i64)> {
181 let pieces: Vec<&str> = line.split("-->").collect();
182 if pieces.len() != 2 {
183 return None;
184 }
185 let start = parse_clock(pieces[0])?;
186 let end_field = pieces[1].split_whitespace().next()?;
187 let end = parse_clock(end_field)?;
188 Some((start, end))
189}
190
191fn timeline_name(tl: (i64, i64)) -> String {
192 format!("{} --> {}", format_clock(tl.0), format_clock(tl.1))
193}
194
195pub fn split_blocks(text: &str) -> Vec<Vec<String>> {
197 let mut blocks: Vec<Vec<String>> = Vec::new();
198 let mut buf: Vec<String> = Vec::new();
199 for line in text.split('\n') {
200 if !line.trim().is_empty() {
201 buf.push(line.trim_end().to_string());
202 } else if !buf.is_empty() {
203 blocks.push(std::mem::take(&mut buf));
204 }
205 }
206 if !buf.is_empty() {
207 blocks.push(buf);
208 }
209 blocks
210}
211
212fn first_timeline_index(block: &[String]) -> Option<usize> {
213 block.iter().position(|ln| ln.contains("-->"))
214}
215
216pub fn parse_srt(text: &str) -> Result<Vec<(String, String)>> {
218 let mut cues = Vec::new();
219 for block in split_blocks(text) {
220 let Some(idx) = first_timeline_index(&block) else {
221 continue;
222 };
223 let Some(tl) = parse_timeline(&block[idx]) else {
224 let shown: String = block[idx].trim().chars().take(120).collect();
225 return Err(DocError::corrupt(format!("SRT 时间轴格式非法: {shown}")));
226 };
227 cues.push((timeline_name(tl), block[idx + 1..].join("\n")));
228 }
229 Ok(cues)
230}
231
232pub fn parse_vtt(text: &str) -> Result<Vec<(String, String)>> {
234 let first = text
235 .split('\n')
236 .find(|ln| !ln.trim().is_empty())
237 .unwrap_or("");
238 if !first.trim().to_uppercase().starts_with("WEBVTT") {
239 return Err(DocError::corrupt("不是合法的 WebVTT(缺少 WEBVTT 头)"));
240 }
241 let mut cues = Vec::new();
242 for block in split_blocks(text) {
243 let head = block[0].trim();
244 let keyword = head.split(' ').next().unwrap_or("").to_uppercase();
245 if matches!(keyword.as_str(), "NOTE" | "STYLE" | "REGION") {
246 continue;
247 }
248 if head.to_uppercase().starts_with("WEBVTT") {
249 continue;
250 }
251 let Some(idx) = first_timeline_index(&block) else {
252 continue;
253 };
254 let Some(tl) = parse_timeline(&block[idx]) else {
255 let shown: String = block[idx].trim().chars().take(120).collect();
256 return Err(DocError::corrupt(format!("VTT 时间轴格式非法: {shown}")));
257 };
258 cues.push((timeline_name(tl), block[idx + 1..].join("\n")));
259 }
260 Ok(cues)
261}
262
263fn lower_ascii(s: &str) -> String {
264 s.to_ascii_lowercase()
265}
266
267pub fn parse_ass(text: &str) -> Result<Vec<(String, String)>> {
269 let mut fields: Option<Vec<String>> = None;
270 let mut section = String::new();
271 let mut cues = Vec::new();
272
273 for line in text.split('\n') {
274 let row = line.trim();
275 if row.is_empty() {
276 continue;
277 }
278 if row.starts_with('[') && row.ends_with(']') {
279 section = lower_ascii(row[1..row.len() - 1].trim());
280 continue;
281 }
282 if section != "events" {
283 continue;
284 }
285 let low = lower_ascii(row);
286 if low.starts_with("format:") {
287 fields = Some(
288 row["format:".len()..]
289 .split(',')
290 .map(|f| lower_ascii(f.trim()))
291 .collect(),
292 );
293 continue;
294 }
295 if low.starts_with("comment:") {
296 continue;
297 }
298 if !low.starts_with("dialogue:") {
299 continue;
300 }
301 let Some(names) = fields.as_ref() else {
302 return Err(DocError::corrupt("ASS 的 Dialogue 出现在 Format 行之前"));
303 };
304 let payload = row["dialogue:".len()..].trim_start();
305 let values: Vec<&str> = payload.splitn(names.len(), ',').collect();
307 if values.len() != names.len() {
308 return Err(DocError::corrupt(format!(
309 "ASS Dialogue 字段数不足(期望 {})",
310 names.len()
311 )));
312 }
313 let field = |name: &str| -> Option<&str> {
314 names
315 .iter()
316 .position(|f| f == name)
317 .map(|i| values[i].trim())
318 };
319 let (Some(start), Some(end)) = (
320 field("start").and_then(parse_clock),
321 field("end").and_then(parse_clock),
322 ) else {
323 let shown: String = payload.chars().take(120).collect();
324 return Err(DocError::corrupt(format!("ASS 时间轴格式非法: {shown}")));
325 };
326 cues.push((
327 timeline_name((start, end)),
328 ass_text(field("text").unwrap_or("")),
329 ));
330 }
331
332 if fields.is_none() {
333 return Err(DocError::corrupt("ASS 未找到 [Events] 段的 Format 行"));
334 }
335 Ok(cues)
336}
337
338pub fn read_subtitle(path: &str) -> Result<String> {
340 let raw =
341 std::fs::read(path).map_err(|e| DocError::corrupt(format!("无法读取字幕文件: {e}")))?;
342 let raw = raw.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(&raw);
343 let text = std::str::from_utf8(raw)
344 .map_err(|_| DocError::corrupt("字幕文件不是合法 UTF-8:请另存为 UTF-8 后重试"))?;
345 Ok(text.replace("\r\n", "\n").replace('\r', "\n"))
346}
347
348pub fn parse_cues(path: &str, kind: &str) -> Result<Vec<(String, String)>> {
350 let text = read_subtitle(path)?;
351 let raw = match kind {
352 "vtt" => parse_vtt(&text)?,
353 "ass" | "ssa" => parse_ass(&text)?,
354 _ => parse_srt(&text)?,
355 };
356 let mut out: Vec<(String, String)> = Vec::new();
357 for (name, body) in raw {
358 let cleaned = clean_text(&body);
359 if !cleaned.is_empty() {
360 out.push((name, cleaned));
361 }
362 }
363 if out.is_empty() {
364 return Err(DocError::corrupt(format!("{kind} 未找到任何非空字幕条目")));
365 }
366 Ok(out)
367}
368
369pub struct SubtitleParseCapability {
371 pub id: &'static str,
372 pub kind: &'static str,
373}
374
375impl ParseCapability for SubtitleParseCapability {
376 fn id(&self) -> &str {
377 self.id
378 }
379 fn formats(&self) -> &[&str] {
380 match self.kind {
381 "vtt" => &["vtt"],
382 "ass" => &["ass", "ssa"],
383 _ => &["srt"],
384 }
385 }
386 fn parse(&self, path: &str) -> Result<RawDoc> {
387 let cues = parse_cues(path, self.kind)?;
388 let pages = cues
389 .into_iter()
390 .map(|(name, text)| RawPage {
391 name: Some(name),
392 items: vec![RawTextItem {
393 text,
394 ..Default::default()
395 }],
396 ..Default::default()
397 })
398 .collect();
399 Ok(RawDoc {
400 pages,
401 ..Default::default()
402 })
403 }
404}
405
406pub struct SubtitlePlugin;
408
409impl FormatPlugin for SubtitlePlugin {
410 fn id(&self) -> &str {
411 "subtitle"
412 }
413 fn formats(&self) -> &[&str] {
414 FORMATS
415 }
416 fn default_parse_chain(&self) -> Vec<&'static str> {
417 vec![
418 "subtitle.read.srt",
419 "subtitle.read.vtt",
420 "subtitle.read.ass",
421 ]
422 }
423 fn default_process_chain(&self) -> Vec<&'static str> {
424 vec![]
425 }
426 fn assemble(&self, raw: &RawDoc) -> Document {
427 let pages = raw
428 .pages
429 .iter()
430 .map(|rp| {
431 let blocks: Vec<Block> = rp
432 .items
433 .iter()
434 .map(|item| Block {
435 kind: BlockKind::Paragraph,
436 content: item.text.clone(),
437 ..Default::default()
438 })
439 .collect();
440 let text = rp
441 .items
442 .iter()
443 .map(|i| i.text.as_str())
444 .collect::<Vec<_>>()
445 .join("\n\n");
446 Page {
447 name: rp.name.clone(),
448 blocks,
449 text,
450 ..Default::default()
451 }
452 })
453 .collect();
454 Document {
455 pages,
456 ..Default::default()
457 }
458 }
459}
460
461pub fn register_defaults(reg: &mut Registry) {
463 for (id, kind) in [
464 ("subtitle.read.srt", "srt"),
465 ("subtitle.read.vtt", "vtt"),
466 ("subtitle.read.ass", "ass"),
467 ] {
468 let _ = reg.register_parse(Arc::new(SubtitleParseCapability { id, kind }), None);
469 }
470 reg.register_plugin(Arc::new(SubtitlePlugin));
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn clock_parses_and_normalizes() {
479 assert_eq!(parse_clock("00:00:01,000"), Some(1000));
480 assert_eq!(parse_clock("0:00:03.50"), Some(3500));
481 assert_eq!(parse_clock("01:02:03.004"), Some(3_723_004));
482 assert_eq!(parse_clock(" 1:00 "), None); assert_eq!(parse_clock("abc"), None);
484 assert_eq!(format_clock(3_723_004), "01:02:03,004");
485 assert_eq!(format_clock(500), "00:00:00,500");
486 }
487
488 #[test]
489 fn timeline_ignores_trailing_settings() {
490 assert_eq!(
491 parse_timeline("00:00:01,000 --> 00:00:02,500 X1:0 X2:1"),
492 Some((1000, 2500))
493 );
494 assert_eq!(
495 parse_timeline("00:01.000 --> 00:02.000 line:0 align:start"),
496 Some((1000, 2000))
497 );
498 assert_eq!(parse_timeline("no arrow"), None);
499 }
500
501 #[test]
502 fn srt_cues_keep_multiline_and_strip_tags() {
503 let text = "1\n00:00:01,000 --> 00:00:04,500\n第一句\n<i>第二行</i>\n\n2\n00:00:05,000 --> 00:00:07,000\nA & B\n";
504 let cues = parse_srt(text).unwrap();
505 assert_eq!(cues.len(), 2);
506 assert_eq!(cues[0].0, "00:00:01,000 --> 00:00:04,500");
507 assert_eq!(clean_text(&cues[0].1), "第一句\n第二行");
508 assert_eq!(clean_text(&cues[1].1), "A & B");
509 }
510
511 #[test]
512 fn srt_missing_arrow_blocks_are_skipped() {
513 let text = "1\n00:00:01,000 --> 00:00:02,000\n正文\n\n游离文本\n";
514 assert_eq!(parse_srt(text).unwrap().len(), 1);
515 }
516
517 #[test]
518 fn vtt_skips_note_and_requires_header() {
519 let text = "WEBVTT - 标题\n\nNOTE 注释\n不该出现\n\nc1\n00:00:01.000 --> 00:00:02.500 line:0\n<v 旁白>你好</v>\n";
520 let cues = parse_vtt(text).unwrap();
521 assert_eq!(cues.len(), 1);
522 assert_eq!(clean_text(&cues[0].1), "你好");
523
524 let err = parse_vtt("1\n00:00:01,000 --> 00:00:02,000\nx\n").unwrap_err();
525 assert_eq!(err.code.as_str(), "corrupt");
526 }
527
528 #[test]
529 fn ass_keeps_commas_in_text_and_drops_overrides() {
530 let text = "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:01.00,0:00:03.50,Default,,0,0,0,,{\\pos(1,2)}你好,世界\\N第二行\nComment: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,忽略\n";
531 let cues = parse_ass(text).unwrap();
532 assert_eq!(cues.len(), 1);
533 assert_eq!(cues[0].0, "00:00:01,000 --> 00:00:03,500");
534 assert_eq!(clean_text(&cues[0].1), "你好,世界\n第二行");
535 }
536
537 #[test]
538 fn ass_without_events_is_corrupt() {
539 let err = parse_ass("[Script Info]\nTitle: t\n").unwrap_err();
540 assert_eq!(err.code.as_str(), "corrupt");
541 }
542
543 #[test]
544 fn registry_resolves_every_subtitle_format() {
545 let mut reg = Registry::new();
546 crate::plugins::register_defaults(&mut reg);
547 let pol = crate::policy::BackendPolicy::default();
548 let default = [
549 "subtitle.read.srt",
550 "subtitle.read.vtt",
551 "subtitle.read.ass",
552 ];
553 for (fmt, want) in [
554 ("srt", "subtitle.read.srt"),
555 ("vtt", "subtitle.read.vtt"),
556 ("ass", "subtitle.read.ass"),
557 ("ssa", "subtitle.read.ass"),
558 ] {
559 let chain = reg.resolve_parse_chain(fmt, &pol, &default);
560 assert_eq!(chain.len(), 1, "{fmt} 解析链为空");
561 assert_eq!(chain[0].id(), want, "{fmt}");
562 }
563 }
564
565 #[test]
566 fn engine_parses_subtitle_end_to_end() {
567 let dir = std::env::temp_dir();
568 let cases: [(&str, &str, &str); 3] = [
569 (
570 "e2e.srt",
571 "1\n00:00:01,000 --> 00:00:02,000\n第一句\n",
572 "第一句",
573 ),
574 (
575 "e2e.vtt",
576 "WEBVTT\n\n00:00:01.000 --> 00:00:02.500\n你好\n",
577 "你好",
578 ),
579 (
580 "e2e.ass",
581 "[Events]\nFormat: Layer, Start, End, Text\nDialogue: 0,0:00:01.00,0:00:02.50,台词\n",
582 "台词",
583 ),
584 ];
585 for (name, body, want) in cases {
586 let p = dir.join(format!("docparser_{name}"));
587 std::fs::write(&p, body).unwrap();
588 let mut reg = Registry::new();
589 crate::plugins::register_defaults(&mut reg);
590 let doc = crate::engine::DocumentEngine::new(reg)
591 .parse(p.to_str().unwrap())
592 .unwrap_or_else(|e| panic!("{name} 解析失败: {e}"));
593 assert_eq!(doc.pages.len(), 1, "{name}");
594 assert_eq!(doc.pages[0].text, want, "{name}");
595 std::fs::remove_file(&p).ok();
596 }
597 }
598
599 #[test]
600 fn plugin_makes_one_page_per_cue() {
601 let raw = RawDoc {
602 pages: vec![RawPage {
603 name: Some("00:00:01,000 --> 00:00:02,000".into()),
604 items: vec![RawTextItem {
605 text: "台词".into(),
606 ..Default::default()
607 }],
608 ..Default::default()
609 }],
610 ..Default::default()
611 };
612 let doc = SubtitlePlugin.assemble(&raw);
613 assert_eq!(doc.pages.len(), 1);
614 assert_eq!(
615 doc.pages[0].name.as_deref(),
616 Some("00:00:01,000 --> 00:00:02,000")
617 );
618 assert_eq!(doc.pages[0].text, "台词");
619 assert_eq!(doc.pages[0].blocks.len(), 1);
620 }
621}