subtitler 2.6.1

Parse, convert, validate, edit, and generate subtitles in 15 formats (SRT, VTT, ASS/SSA, MicroDVD, SubViewer, TTML, SBV, LRC, SAMI, MPL2, SCC, EBU STL, DFXP, Whisper JSON). Full CLI included.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! SAMI (Synchronized Accessible Media Interchange) parser and generator.
//!
//! Microsoft-developed subtitle format used in Windows Media Player and
//! widely adopted in Asian markets. Supports styling and multi-language subtitles.
//!
//! Format: HTML-like structure with `<Sync>` and `<P>` tags.

use crate::error::SubtitleError;
use crate::model::{Format, Subtitle, SubtitleFile};
use crate::types::AnyResult;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::LazyLock;

static RE_SYNC_TAG: LazyLock<Regex> =
  LazyLock::new(|| Regex::new(r"(?i)<Sync[^>]*Start\s*=\s*(\d+)[^>]*>").unwrap());

static RE_P_TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<P[^>]*>(.*?)</P>").unwrap());

static RE_STRIP_TAGS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());

/// Case-insensitive search for a literal needle, returns the byte index of the
/// match start. Used for HTML tag lookups where `<Head>`, `<HEAD>`, `<head>`
/// must all be recognized.
fn find_ci(haystack: &str, needle: &str) -> Option<usize> {
  haystack.to_lowercase().find(&needle.to_lowercase())
}

/// Case-insensitive search returning both start and end offset of the match,
/// so callers can slice the original (case-preserved) text.
fn find_ci_range(haystack: &str, needle: &str) -> Option<(usize, usize)> {
  let lower = haystack.to_lowercase();
  let needle = needle.to_lowercase();
  let start = lower.find(&needle)?;
  Some((start, start + needle.len()))
}

/// SAMI subtitle data including header and styles.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct SamiData {
  /// Optional header content (Head section)
  pub header: Option<String>,
  /// Style definitions (CSS)
  pub styles: HashMap<String, String>,
  /// Subtitle entries
  pub subtitles: Vec<Subtitle>,
}

impl SamiData {
  /// Parse SAMI content into structured data.
  pub fn parse(content: &str) -> Result<Self, SubtitleError> {
    let mut header = None;
    let mut styles = HashMap::new();

    // Extract header (Head section) — 大小写不敏感,兼容 <HEAD>/<head>
    if let Some((head_start, _)) = find_ci_range(content, "<Head>") {
      if let Some(head_end) = find_ci(content, "</Head>") {
        header = Some(content[head_start..head_end + 7].to_string());
      }
    }

    // Extract styles (Style section) — 大小写不敏感
    if let Some(style_start) = find_ci(content, "<Style") {
      if let Some(style_end) = find_ci(content, "</Style>") {
        let style_content = &content[style_start..style_end + 8];
        // Simple CSS parsing - extract class definitions
        if let Some(css_start) = style_content.find('>') {
          let css = &style_content[css_start + 1..style_content.len().saturating_sub(8)];
          for line in css.lines() {
            let trimmed = line.trim();
            if trimmed.starts_with('.') {
              let parts: Vec<&str> = trimmed.splitn(2, '{').collect();
              if parts.len() == 2 {
                let class_name = parts[0].trim()[1..].to_string();
                let style_def = parts[1].trim();
                styles.insert(class_name, style_def.to_string());
              }
            }
          }
        }
      }
    }

    // 两遍扫描:第一遍收集所有 Sync 的起始时间,用于第二遍推导结束时间。
    // 旧的实现把每条字幕的 end 硬编码为 start + 3000ms,丢失了真实时长信息。
    let mut sync_starts: Vec<u64> = Vec::new();
    for caps in RE_SYNC_TAG.captures_iter(content) {
      let start_ms: u64 = caps[1].parse().unwrap_or(0);
      sync_starts.push(start_ms);
    }
    sync_starts.sort_unstable();
    sync_starts.dedup();

    // 第二遍:解析每个 Sync 块,用「下一个更晚的 Sync start」作为 end。
    // 默认兜底 3 秒,保证最后一条字幕仍有合理时长。
    const DEFAULT_TAIL_MS: u64 = 3000;
    let mut subtitles: Vec<Subtitle> = Vec::with_capacity(64);
    let mut pos = 0;
    while let Some(sync_match) = RE_SYNC_TAG.captures(&content[pos..]) {
      let full_match = sync_match.get(0).unwrap();
      let start_ms: u64 = sync_match[1].parse().unwrap_or(0);

      // Find the end of this Sync block — 大小写不敏感
      let sync_end = match find_ci(&content[pos..], "</Sync>") {
        Some(offset) => offset,
        None => break,
      };

      let sync_block = &content[pos + full_match.start()..pos + sync_end + 7];

      // 推导 end:取首个严格大于当前 start 的 Sync 时间;找不到则加默认尾段。
      let end_ms = sync_starts
        .iter()
        .copied()
        .find(|&t| t > start_ms)
        .unwrap_or(start_ms + DEFAULT_TAIL_MS);

      // Extract P tag content
      for p_match in RE_P_TAG.captures_iter(sync_block) {
        let p_content = &p_match[1];
        let text = RE_STRIP_TAGS.replace_all(p_content, "").to_string();
        let text = text.trim().to_string();

        if !text.is_empty() {
          subtitles.push(Subtitle::new(start_ms, end_ms, &text));
          break; // Only take first P tag per Sync
        }
      }

      pos += sync_end + 7;
    }

    Ok(SamiData {
      header,
      styles,
      subtitles,
    })
  }

  /// Convert to Vec<Subtitle> for compatibility.
  pub fn to_subtitles(&self) -> Vec<Subtitle> {
    self.subtitles.clone()
  }

  /// Serialize back to SAMI format.
  pub fn render(&self) -> String {
    let mut buf = String::from("<SAMI>\n");

    // Header
    if let Some(ref head) = self.header {
      buf.push_str(head);
      buf.push('\n');
    } else {
      buf.push_str("<Head>\n");
      buf.push_str("<Title>Subtitles</Title>\n");
      buf.push_str("<Style Type=\"text/css\">\n");
      buf.push_str("<!--\n");
      buf.push_str("  .ENCC {Name: English; lang: en-US;}\n");
      buf.push_str("-->\n");
      buf.push_str("</Style>\n");
      buf.push_str("</Head>\n\n");
    }

    // Body
    buf.push_str("<Body>\n");

    for sub in &self.subtitles {
      buf.push_str(&format!(
        "<Sync Start={}><P>{}</P></Sync>\n",
        sub.start, sub.text
      ));
    }

    buf.push_str("</Body>\n");
    buf.push_str("</SAMI>\n");

    buf
  }
}

/// Parse SAMI content into a SubtitleFile.
pub fn parse_content(content: &str) -> AnyResult<SubtitleFile> {
  let data = SamiData::parse(content)?;
  Ok(SubtitleFile::Sami(data))
}

/// Parse SAMI from a byte slice.
pub fn parse_bytes(data: &[u8]) -> AnyResult<SubtitleFile> {
  let text = crate::encoding::decode_to_string(data)?;
  parse_content(&text)
}

/// Parse a SAMI file asynchronously.
#[cfg(not(target_arch = "wasm32"))]
pub async fn parse_file(path: impl AsRef<std::path::Path>) -> AnyResult<SubtitleFile> {
  let text = tokio::fs::read_to_string(path).await?;
  parse_content(&text)
}

/// Parse a SAMI file from a URL (requires `http` feature).
#[cfg(feature = "http")]
pub async fn parse_url(url: &str) -> AnyResult<SubtitleFile> {
  let response = reqwest::get(url).await?;
  let content = response.text().await?;
  parse_content(&content)
}

/// Detect if data looks like SAMI.
pub fn detect_format(data: &[u8]) -> Option<crate::model::Format> {
  let text = crate::encoding::try_decode_for_detection(data)?;
  if text.contains("<SAMI>") || text.contains("<Sync") {
    return Some(crate::model::Format::Sami);
  }
  None
}

/// Write subtitles to a file in SAMI format.
///
/// `policy` controls overwrite behavior (None = default Overwrite).
/// Omits the optional header; to include one, call `to_string`
/// directly and write the result with `tokio::fs::write`.
#[cfg(not(target_arch = "wasm32"))]
pub async fn generate(
  subtitles: &[Subtitle],
  file_path: impl AsRef<std::path::Path>,
  policy: Option<crate::model::WritePolicy>,
) -> AnyResult<String> {
  let content = to_string(subtitles, None);
  let path = file_path.as_ref();
  crate::io::write_with_policy(path, content.as_bytes(), policy).await?;
  Ok(path.to_string_lossy().into_owned())
}

/// Streaming write — serializes via `to_string` and writes in one chunk.
///
/// For formats like SAMI where incremental XML/HTML generation would be
/// complex, we buffer to a string and write_all. This matches the
/// `generate` semantics; for very large inputs consider `to_string` +
/// custom chunking.
#[cfg(not(target_arch = "wasm32"))]
pub async fn write_stream<W: tokio::io::AsyncWrite + Unpin>(
  subtitles: &[Subtitle],
  header: Option<&str>,
  writer: &mut W,
) -> AnyResult<()> {
  use tokio::io::AsyncWriteExt;
  let content = to_string(subtitles, header);
  writer.write_all(content.as_bytes()).await?;
  writer.flush().await?;
  Ok(())
}

/// Serialize subtitles to SAMI format.
pub fn to_string(subtitles: &[Subtitle], header: Option<&str>) -> String {
  let data = SamiData {
    header: header.map(|s| s.to_string()),
    styles: HashMap::new(),
    subtitles: subtitles.to_vec(),
  };
  data.render()
}

/// Streaming parser entry point — yields subtitles one at a time
/// without allocating a full `Vec`.
pub fn parse_stream<'a>(content: &'a str) -> SamiStream<'a> {
  SamiStream::new(content)
}

pub struct SamiStream<'a> {
  content: &'a str,
  pos: usize,
}

impl<'a> SamiStream<'a> {
  pub fn new(content: &'a str) -> Self {
    SamiStream { content, pos: 0 }
  }
}

impl<'a> Iterator for SamiStream<'a> {
  type Item = AnyResult<Subtitle>;

  fn next(&mut self) -> Option<Self::Item> {
    // 流式场景下无法预知后续 Sync 的 start,故 end 取默认尾段。
    // 语义上与 SamiData::parse 的「最后一条/无后续」兜底一致。
    const DEFAULT_TAIL_MS: u64 = 3000;
    while self.pos < self.content.len() {
      let sync_match = match RE_SYNC_TAG.captures(&self.content[self.pos..]) {
        Some(m) => m,
        None => break,
      };
      let full_match = sync_match.get(0).unwrap();
      let start_ms: u64 = match sync_match[1].parse() {
        Ok(v) => v,
        Err(e) => {
          // 解析失败时跳过本 Sync 标签,避免死循环
          self.pos += full_match.end();
          return Some(Err(
            SubtitleError::InvalidTimestamp {
              format: Format::Sami,
              value: e.to_string(),
            }
            .into(),
          ));
        }
      };

      // 找到本 Sync 块的闭合标签
      let sync_end_rel = match find_ci(&self.content[self.pos..], "</Sync>") {
        Some(off) => off,
        None => break,
      };
      let sync_block = &self.content[self.pos + full_match.start()..self.pos + sync_end_rel + 7];

      // 无论是否产出字幕,都推进到闭合标签之后,避免重复处理
      self.pos += sync_end_rel + 7;

      // 提取 P 内容
      if let Some(p_match) = RE_P_TAG.captures(sync_block) {
        let p_content = &p_match[1];
        let text = RE_STRIP_TAGS.replace_all(p_content, "").to_string();
        let text = text.trim().to_string();

        if !text.is_empty() {
          return Some(Ok(Subtitle::new(
            start_ms,
            start_ms + DEFAULT_TAIL_MS,
            &text,
          )));
        }
      }
    }
    None
  }
}

impl<'a> crate::model::StreamingParser for SamiStream<'a> {}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn test_parse_basic() {
    let content = r#"<SAMI>
<Head><Title>Test</Title></Head>
<Body>
<Sync Start=1000><P>First subtitle</P></Sync>
<Sync Start=4000><P>Second subtitle</P></Sync>
</Body>
</SAMI>"#;

    let file = parse_content(content).unwrap();
    if let SubtitleFile::Sami(data) = file {
      assert_eq!(data.subtitles.len(), 2);
      assert_eq!(data.subtitles[0].start, 1000);
      assert_eq!(data.subtitles[0].text, "First subtitle");
      assert_eq!(data.subtitles[1].start, 4000);
    } else {
      panic!("Expected Sami variant");
    }
  }

  #[test]
  fn test_round_trip() {
    let content = r#"<SAMI>
<Body>
<Sync Start=1000><P>Hello</P></Sync>
</Body>
</SAMI>"#;

    let file = parse_content(content).unwrap();
    let subs = match &file {
      SubtitleFile::Sami(data) => &data.subtitles,
      _ => panic!("Expected Sami variant"),
    };
    let output = to_string(subs, None);
    assert!(output.contains("Hello"));
  }

  #[test]
  fn test_detect() {
    assert!(detect_format(b"<SAMI><Body></Body></SAMI>").is_some());
    assert!(detect_format(b"WEBVTT").is_none());
  }

  #[test]
  fn test_end_time_uses_next_sync() {
    // end_ms 应取下一个 Sync 的 start,而非硬编码 start + 3000
    let content = r#"<SAMI>
<Body>
<Sync Start=1000><P>First</P></Sync>
<Sync Start=5000><P>Second</P></Sync>
<Sync Start=20000><P>Third</P></Sync>
</Body>
</SAMI>"#;
    let file = parse_content(content).unwrap();
    if let SubtitleFile::Sami(data) = file {
      assert_eq!(data.subtitles[0].end, 5000, "第一条 end 应为下一条的 start");
      assert_eq!(
        data.subtitles[1].end, 20000,
        "第二条 end 应为第三条的 start"
      );
      // 最后一条无后续,应使用默认尾段 3000ms
      assert_eq!(data.subtitles[2].end, 23000, "最后一条 end = start + 3000");
    } else {
      panic!("Expected Sami variant");
    }
  }

  #[test]
  fn test_parse_case_insensitive_tags() {
    // 真实 SAMI 文件常见大写/混合大小写标签
    let content = r#"<SAMI>
<HEAD><Title>Test</Title></HEAD>
<BODY>
<SYNC START=1000><P>First</P></SYNC>
<SYNC START=4000><P>Second</P></SYNC>
</BODY>
</SAMI>"#;
    let file = parse_content(content).unwrap();
    if let SubtitleFile::Sami(data) = file {
      assert_eq!(
        data.subtitles.len(),
        2,
        "大小写变体的 Sync/P 标签必须被识别"
      );
      assert_eq!(data.subtitles[0].start, 1000);
      assert_eq!(data.subtitles[0].text, "First");
      assert_eq!(data.subtitles[1].start, 4000);
      assert!(data.header.is_some(), "大小写变体的 <HEAD> 必须被识别");
    } else {
      panic!("Expected Sami variant");
    }
  }
}