skrt/lib.rs
1//! A library for parsing and manipulating SubRip Text (SRT) subtitle files.
2//!
3//! SRT is a simple, widely-supported subtitle format used by media players,
4//! video editors, and streaming platforms. Each subtitle entry consists of:
5//! - A sequential index number
6//! - A timestamp range (start --> end)
7//! - One or more lines of text
8//!
9//! # Example
10//!
11//! ```
12//! use skrt::{Srt, Timestamp};
13//!
14//! // Parse an existing SRT file
15//! let data = r#"1
16//! 00:00:01,000 --> 00:00:04,000
17//! Hello, world!
18//!
19//! 2
20//! 00:00:05,000 --> 00:00:08,000
21//! This is a subtitle.
22//!
23//! "#;
24//!
25//! let srt = Srt::try_parse(data).unwrap();
26//! assert_eq!(2, srt.subtitles().len());
27//!
28//! // Or build one programmatically
29//! let mut srt = Srt::new();
30//! srt.add_subtitle(
31//! Timestamp::from_millis(1000).unwrap(),
32//! Timestamp::from_millis(4000).unwrap(),
33//! "Hello, world!".into(),
34//! );
35//!
36//! let output = srt.serialize();
37//! ```
38//!
39//! # Format Details
40//!
41//! This crate handles common SRT variations:
42//! - Both LF and CRLF line endings
43//! - Optional UTF-8 BOM at the start of the file
44//! - Trailing whitespace after timestamps
45//! - Files that don't end with a blank line
46//!
47//! Timestamps follow the format `HH:MM:SS,mmm` where:
48//! - `HH` = hours (00-99)
49//! - `MM` = minutes (00-59)
50//! - `SS` = seconds (00-59)
51//! - `mmm` = milliseconds (000-999)
52
53#![warn(rust_2018_idioms)]
54
55use std::{
56 borrow::Cow,
57 cmp::Ordering,
58 error::Error,
59 fmt::{self, Display, Write},
60};
61
62/// Error type for SRT parsing and manipulation operations.
63///
64/// Errors that include a `position` field report the byte offset into the
65/// original input string where the error occurred. This is useful for
66/// displaying user-friendly error messages with location information.
67///
68/// # Example
69///
70/// ```
71/// use skrt::{Srt, SrtError};
72///
73/// let bad_input = "1\n00:XX:00,000 --> 00:00:01,000\nHello\n\n";
74/// let err = Srt::try_parse(bad_input).unwrap_err();
75///
76/// match err {
77/// SrtError::InvalidTimestamp { position } => {
78/// println!("Bad timestamp at byte {position}");
79/// }
80/// _ => {}
81/// }
82/// ```
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum SrtError {
85 /// Input ended unexpectedly.
86 ///
87 /// Returned when the parser reaches the end of input while expecting
88 /// more data, such as a complete timestamp.
89 UnexpectedEof,
90
91 /// Invalid or missing sequence number.
92 ///
93 /// Each subtitle block must begin with a positive integer sequence number.
94 /// This error is returned when the sequence number is missing, empty,
95 /// or contains non-digit characters.
96 InvalidSequenceNumber {
97 /// Byte offset where the invalid sequence number was found.
98 position: usize,
99 },
100
101 /// Timestamp format is invalid.
102 ///
103 /// Timestamps must follow the format `HH:MM:SS,mmm`. This error is
104 /// returned for malformed timestamps, invalid digit ranges (e.g.,
105 /// minutes > 59), or incorrect separators.
106 InvalidTimestamp {
107 /// Byte offset where the invalid timestamp begins.
108 position: usize,
109 },
110
111 /// Timestamp value exceeds the maximum representable range.
112 ///
113 /// The SRT format conventionally supports hours from 00-99, giving a
114 /// maximum timestamp of `99:59:59,999` (359,999,999 milliseconds).
115 TimestampOutOfRange,
116
117 /// Timestamp value is too small or large to be represented as a `i64`.
118 TimestampOverflow,
119
120 /// Shift operation would result in a negative timestamp.
121 ///
122 /// Returned by [`Timestamp::shift_millis`] when the shift amount would
123 /// make the timestamp negative.
124 NegativeTimestamp,
125
126 /// Expected ` --> ` separator between timestamps.
127 ///
128 /// The start and end timestamps must be separated by exactly ` --> `
129 /// (space, dash, dash, greater-than, space).
130 ExpectedTimeSeparator {
131 /// Byte offset where the separator was expected.
132 position: usize,
133 },
134
135 /// Expected whitespace or newline character.
136 ///
137 /// Returned when a newline was expected (e.g., after a timestamp line)
138 /// but other characters were found.
139 ExpectedNewline {
140 /// Byte offset where the newline was expected.
141 position: usize,
142 },
143}
144
145impl Display for SrtError {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 match self {
148 SrtError::UnexpectedEof => write!(f, "unexpected end of input"),
149 SrtError::InvalidSequenceNumber { position } => {
150 write!(f, "invalid sequence number at byte {position}")
151 }
152 SrtError::InvalidTimestamp { position } => {
153 write!(f, "invalid timestamp at byte {position}")
154 }
155 SrtError::TimestampOutOfRange => {
156 write!(f, "timestamp exceeds maximum range (99:59:59,999)")
157 }
158 SrtError::TimestampOverflow => write!(f, "timestamp overflows `i64`"),
159 SrtError::NegativeTimestamp => {
160 write!(f, "operation would result in negative timestamp")
161 }
162 SrtError::ExpectedTimeSeparator { position } => {
163 write!(f, "expected ' --> ' at byte {position}")
164 }
165 SrtError::ExpectedNewline { position } => {
166 write!(f, "expected newline at byte {position}")
167 }
168 }
169 }
170}
171
172impl Error for SrtError {}
173
174/// A convenient Result type alias for SRT operations.
175pub type Result<T> = std::result::Result<T, SrtError>;
176
177/// A collection of subtitles representing an SRT file.
178///
179/// `Srt` is the main type for working with subtitle data. You can either
180/// parse an existing SRT string with [`Srt::try_parse`] or build a new
181/// subtitle collection with [`Srt::new`] and [`Srt::add_subtitle`].
182///
183/// # Lifetime
184///
185/// The `'a` lifetime allows parsed subtitles to borrow text directly from
186/// the input string (zero-copy parsing). When building subtitles
187/// programmatically with owned `String` data, use `Srt<'static>`.
188///
189/// # Example
190///
191/// ```
192/// use skrt::{Srt, Timestamp};
193///
194/// let mut srt = Srt::new();
195///
196/// srt.add_subtitle(
197/// Timestamp::from_millis(0).unwrap(),
198/// Timestamp::from_millis(2000).unwrap(),
199/// "First subtitle"".into(),
200/// );
201///
202/// srt.add_subtitle(
203/// Timestamp::from_millis(2500).unwrap(),
204/// Timestamp::from_millis(5000).unwrap(),
205/// "Second subtitle".into(),
206/// );
207///
208/// println!("{}", srt.serialize());
209/// ```
210#[derive(Debug, Default, Clone, PartialEq, Eq)]
211pub struct Srt<'a> {
212 subtitles: Vec<Subtitle<'a>>,
213}
214
215/// A single subtitle entry.
216///
217/// Each subtitle has a sequence number, start and end timestamps, and text content. The text may
218/// contain multiple lines and can include basic HTML-like formatting tags (e.g., `<i>`, `<b>`)
219/// which are preserved as-is.
220///
221/// Subtitles are typically accessed through [`Srt::subtitles`] or [`Srt::iter`].
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct Subtitle<'a> {
224 seq: usize,
225 start: Timestamp,
226 end: Timestamp,
227 text: Cow<'a, str>,
228}
229
230impl<'a> Subtitle<'a> {
231 /// Returns the sequence number of this subtitle.
232 ///
233 /// Sequence numbers start at 1 and increment for each subtitle, though parsed files may have
234 /// gaps or non-sequential numbering.
235 pub fn seq(&self) -> usize {
236 self.seq
237 }
238
239 /// Returns the start timestamp of this subtitle.
240 pub fn start(&self) -> Timestamp {
241 self.start
242 }
243
244 /// Returns the end timestamp of this subtitle.
245 pub fn end(&self) -> Timestamp {
246 self.end
247 }
248
249 /// Returns the text content of this subtitle.
250 ///
251 /// The text may contain newlines for multi-line subtitles and may
252 /// include formatting tags like `<i>` or `<b>`.
253 pub fn text(&self) -> &str {
254 &self.text
255 }
256
257 /// Sets the sequence number of this subtitle.
258 pub fn set_seq(&mut self, seq: usize) {
259 self.seq = seq;
260 }
261
262 /// Sets the start timestamp.
263 pub fn set_start(&mut self, start: Timestamp) {
264 self.start = start;
265 }
266
267 /// Sets the end timestamp.
268 pub fn set_end(&mut self, end: Timestamp) {
269 self.end = end;
270 }
271
272 /// Sets the subtitle text.
273 pub fn set_text(&mut self, text: Cow<'a, str>) {
274 self.text = text;
275 }
276}
277
278/// A timestamp representing a point in time within media.
279///
280/// Timestamps have millisecond precision and support a range from
281/// `00:00:00,000` to `99:59:59,999`.
282///
283/// # Display Format
284///
285/// When formatted with `Display`, timestamps use the standard SRT format:
286/// `HH:MM:SS,mmm` (e.g., `01:23:45,678`).
287///
288/// # Example
289///
290/// ```
291/// use skrt::Timestamp;
292///
293/// let ts = Timestamp::from_millis(5025000).unwrap(); // 1h 23m 45s
294/// assert_eq!("01:23:45,000", ts.to_string());
295/// assert_eq!(5025000, ts.to_millis());
296///
297/// // Shift the timestamp forward by 500ms
298/// let shifted = ts.shift_millis(500).unwrap();
299/// assert_eq!("01:23:45,500", shifted.to_string());
300/// ```
301#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Hash)]
302pub struct Timestamp {
303 hours: u16,
304 minutes: u16,
305 seconds: u16,
306 milliseconds: u16,
307}
308
309impl Ord for Timestamp {
310 fn cmp(&self, other: &Self) -> Ordering {
311 self.to_millis().cmp(&other.to_millis())
312 }
313}
314
315impl PartialOrd for Timestamp {
316 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
317 Some(self.cmp(other))
318 }
319}
320
321impl Display for Timestamp {
322 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323 write!(
324 f,
325 "{:02}:{:02}:{:02},{:03}",
326 self.hours, self.minutes, self.seconds, self.milliseconds
327 )
328 }
329}
330
331impl Timestamp {
332 /// Creates a new timestamp from a total number of milliseconds.
333 ///
334 /// # Errors
335 ///
336 /// Returns [`SrtError::TimestampOutOfRange`] if `millis` exceeds
337 /// 359,999,999 (equivalent to `99:59:59,999`).
338 ///
339 /// # Example
340 ///
341 /// ```
342 /// use skrt::Timestamp;
343 ///
344 /// let ts = Timestamp::from_millis(3661001).unwrap();
345 /// assert_eq!("01:01:01,001", ts.to_string());
346 /// ```
347 pub fn from_millis(mut millis: u64) -> Result<Self> {
348 let hours = millis / (3_600_000);
349 millis -= hours * 3_600_000;
350 let minutes = millis / (60_000);
351 millis -= minutes * 60_000;
352 let seconds = millis / 1_000;
353 millis -= seconds * 1_000;
354
355 if hours > 99 {
356 return Err(SrtError::TimestampOutOfRange);
357 }
358
359 Ok(Timestamp {
360 hours: hours as u16,
361 minutes: minutes as u16,
362 seconds: seconds as u16,
363 milliseconds: millis as u16,
364 })
365 }
366
367 /// Converts this timestamp to a total number of milliseconds.
368 ///
369 /// # Example
370 ///
371 /// ```
372 /// use skrt::Timestamp;
373 ///
374 /// let ts = Timestamp::from_millis(12345).unwrap();
375 /// assert_eq!(12345, ts.to_millis());
376 /// ```
377 pub fn to_millis(&self) -> u64 {
378 self.hours as u64 * 3_600_000
379 + self.minutes as u64 * 60_000
380 + self.seconds as u64 * 1_000
381 + self.milliseconds as u64
382 }
383
384 /// Returns a new timestamp shifted by the given number of milliseconds.
385 ///
386 /// Positive values shift the timestamp forward (later in time), and
387 /// negative values shift it backward (earlier in time).
388 ///
389 /// # Errors
390 ///
391 /// - Returns [`SrtError::NegativeTimestamp`] if the shift would result
392 /// in a negative timestamp.
393 /// - Returns [`SrtError::TimestampOutOfRange`] if the shift would exceed
394 /// the maximum timestamp value.
395 ///
396 /// # Example
397 ///
398 /// ```
399 /// use skrt::Timestamp;
400 ///
401 /// let ts = Timestamp::from_millis(5000).unwrap();
402 ///
403 /// // Shift forward
404 /// let later = ts.shift_millis(1000).unwrap();
405 /// assert_eq!(6000, later.to_millis());
406 ///
407 /// // Shift backward
408 /// let earlier = ts.shift_millis(-2000).unwrap();
409 /// assert_eq!(3000, earlier.to_millis());
410 /// ```
411 pub fn shift_millis(&self, millis: i64) -> Result<Timestamp> {
412 let t1 = self.to_millis() as i64;
413
414 let t2 = match t1.checked_add(millis) {
415 Some(t) => t,
416 None => return Err(SrtError::TimestampOverflow),
417 };
418
419 if t2 < 0 {
420 return Err(SrtError::NegativeTimestamp);
421 }
422
423 Timestamp::from_millis(t2 as u64)
424 }
425}
426
427impl<'a> Srt<'a> {
428 /// Constructs an empty `Srt` with no subtitles.
429 ///
430 /// # Example
431 ///
432 /// ```
433 /// use skrt::Srt;
434 ///
435 /// let srt = Srt::new();
436 /// assert!(srt.subtitles().is_empty());
437 /// ```
438 pub fn new() -> Srt<'a> {
439 Srt {
440 subtitles: Vec::new(),
441 }
442 }
443
444 /// Returns a slice of all subtitles.
445 ///
446 /// # Example
447 ///
448 /// ```
449 /// use skrt::Srt;
450 ///
451 /// let data = "1\n00:00:00,000 --> 00:00:01,000\nHello\n\n";
452 /// let srt = Srt::try_parse(data).unwrap();
453 ///
454 /// for sub in srt.subtitles() {
455 /// println!("{}: {}", sub.start(), sub.text());
456 /// }
457 /// ```
458 pub fn subtitles(&self) -> &[Subtitle<'a>] {
459 &self.subtitles
460 }
461
462 /// Returns an iterator over the subtitles.
463 ///
464 /// # Example
465 ///
466 /// ```
467 /// use skrt::Srt;
468 ///
469 /// let data = "1\n00:00:00,000 --> 00:00:01,000\nHello\n\n2\n00:00:02,000 --> 00:00:03,000\nWorld\n\n";
470 /// let srt = Srt::try_parse(data).unwrap();
471 ///
472 /// let texts: Vec<_> = srt.iter().map(|s| s.text()).collect();
473 /// assert_eq!(vec!["Hello", "World"], texts);
474 /// ```
475 pub fn iter(&self) -> std::slice::Iter<'_, Subtitle<'a>> {
476 self.subtitles.iter()
477 }
478
479 /// Returns a mutable iterator over the subtitles.
480 ///
481 /// This allows in-place modification of subtitles, such as adjusting
482 /// timestamps or updating text.
483 ///
484 /// # Example
485 ///
486 /// ```
487 /// use skrt::{Srt, Timestamp};
488 ///
489 /// let data = "1\n00:00:00,000 --> 00:00:01,000\nHello\n\n";
490 /// let mut srt = Srt::try_parse(data).unwrap();
491 ///
492 /// // Shift all subtitles forward by 5 seconds
493 /// for sub in srt.iter_mut() {
494 /// sub.set_start(sub.start().shift_millis(5000).unwrap());
495 /// sub.set_end(sub.end().shift_millis(5000).unwrap());
496 /// }
497 ///
498 /// assert_eq!(5000, srt.subtitles()[0].start().to_millis());
499 /// ```
500 pub fn iter_mut<'b>(&'b mut self) -> std::slice::IterMut<'b, Subtitle<'a>> {
501 self.subtitles.iter_mut()
502 }
503
504 /// Adds a new subtitle to the end of the collection.
505 ///
506 /// The sequence number is automatically assigned based on the current
507 /// number of subtitles (i.e., the new subtitle gets the next number
508 /// in sequence).
509 ///
510 /// # Example
511 ///
512 /// ```
513 /// use skrt::{Srt, Timestamp};
514 ///
515 /// let mut srt = Srt::new();
516 ///
517 /// srt.add_subtitle(
518 /// Timestamp::from_millis(0).unwrap(),
519 /// Timestamp::from_millis(1000).unwrap(),
520 /// "First".into(),
521 /// );
522 ///
523 /// srt.add_subtitle(
524 /// Timestamp::from_millis(1000).unwrap(),
525 /// Timestamp::from_millis(2000).unwrap(),
526 /// "Second".into(),
527 /// );
528 ///
529 /// assert_eq!(2, srt.subtitles().len());
530 /// ```
531 pub fn add_subtitle(&mut self, start: Timestamp, end: Timestamp, text: Cow<'a, str>) {
532 let sub = Subtitle {
533 seq: self.subtitles.len() + 1,
534 start,
535 end,
536 text,
537 };
538
539 self.subtitles.push(sub);
540 }
541
542 /// Parses an SRT-formatted string.
543 ///
544 /// This performs zero-copy parsing where possible. Subtitle text is
545 /// borrowed directly from the input string.
546 ///
547 /// # Supported Variations
548 ///
549 /// - LF (`\n`) and CRLF (`\r\n`) line endings
550 /// - Optional UTF-8 BOM at the start
551 /// - Trailing whitespace after timestamp lines
552 /// - Missing final blank line
553 ///
554 /// # Errors
555 ///
556 /// Returns an [`SrtError`] if the input is malformed. The error includes
557 /// position information for debugging.
558 ///
559 /// # Example
560 ///
561 /// ```
562 /// use skrt::Srt;
563 ///
564 /// let data = r#"1
565 /// 00:00:01,000 --> 00:00:04,000
566 /// Hello!
567 ///
568 /// "#;
569 ///
570 /// let srt = Srt::try_parse(data).unwrap();
571 /// assert_eq!(1, srt.subtitles().len());
572 /// ```
573 pub fn try_parse(mut srt: &str) -> Result<Srt<'_>> {
574 let mut subtitles = Vec::new();
575
576 let original = srt;
577 let pos = |remaining: &str| original.len() - remaining.len();
578
579 srt = strip_prefix_bom(srt);
580 while !srt.is_empty() {
581 // sequence number
582 let end = srt
583 .find(|c: char| !c.is_ascii_digit())
584 .ok_or(SrtError::InvalidSequenceNumber { position: pos(srt) })?;
585 let seq = srt[..end]
586 .parse()
587 .map_err(|_| SrtError::InvalidSequenceNumber { position: pos(srt) })?;
588 srt = &srt[end..];
589 let discard = newline_discard(srt, pos(srt))?;
590 srt = &srt[discard..];
591
592 // time start
593 let start_time = parse_time(srt, pos(srt))?;
594 srt = &srt[12..];
595
596 // time separator
597 let b = srt.as_bytes();
598 if b.len() < 5 {
599 return Err(SrtError::UnexpectedEof);
600 }
601 if !matches!(&b[0..5], b" --> ") {
602 return Err(SrtError::ExpectedTimeSeparator { position: pos(srt) });
603 }
604 srt = &srt[5..];
605
606 // time end
607 let end_time = parse_time(srt, pos(srt))?;
608 srt = &srt[12..];
609 let discard = newline_discard(srt, pos(srt))?;
610 srt = &srt[discard..];
611
612 // text
613 let mut it = srt.chars();
614 let mut end = 0;
615 let mut discard = 0;
616 while let Some(c) = it.next() {
617 if c == '\n' {
618 // check for consecutive LF
619 let mut it2 = it.clone();
620 let c2 = it2.next();
621 if let Some('\n') = c2 {
622 discard = 2;
623 break;
624 }
625 } else if c == '\r' {
626 // check for consecutive CRLF
627 let mut it2 = it.clone();
628 let (c2, c3, c4) = (it2.next(), it2.next(), it2.next());
629 if let (Some('\n'), Some('\r'), Some('\n')) = (c2, c3, c4) {
630 discard = 4;
631 break;
632 }
633 }
634
635 end += c.len_utf8();
636 }
637 let text = &srt[..end];
638 srt = &srt[end + discard..];
639
640 subtitles.push(Subtitle {
641 seq,
642 start: start_time,
643 end: end_time,
644 text: text.into(),
645 });
646 }
647
648 Ok(Srt { subtitles })
649 }
650
651 /// Reassigns sequence numbers starting from 1.
652 ///
653 /// This is useful after modifying the subtitle list (e.g., removing
654 /// or reordering entries) to ensure sequence numbers are contiguous.
655 ///
656 /// # Example
657 ///
658 /// ```
659 /// use skrt::{Srt, Timestamp};
660 ///
661 /// let mut srt = Srt::new();
662 /// srt.add_subtitle(
663 /// Timestamp::from_millis(0).unwrap(),
664 /// Timestamp::from_millis(1000).unwrap(),
665 /// "Hello".into(),
666 /// );
667 ///
668 /// // After some modifications...
669 /// srt.resequence();
670 ///
671 /// assert_eq!(1, srt.subtitles()[0].seq());
672 /// ```
673 pub fn resequence(&mut self) {
674 for (i, sub) in self.subtitles.iter_mut().enumerate() {
675 sub.seq = i + 1;
676 }
677 }
678
679 /// Serializes the subtitles to an SRT-formatted string.
680 ///
681 /// The output uses LF line endings. Each subtitle block is separated
682 /// by a blank line.
683 ///
684 /// # Example
685 ///
686 /// ```
687 /// use skrt::{Srt, Timestamp};
688 ///
689 /// let mut srt = Srt::new();
690 /// srt.add_subtitle(
691 /// Timestamp::from_millis(1000).unwrap(),
692 /// Timestamp::from_millis(2000).unwrap(),
693 /// "Hello, world!".into(),
694 /// );
695 ///
696 /// let output = srt.serialize();
697 /// assert!(output.contains("00:00:01,000 --> 00:00:02,000"));
698 /// assert!(output.contains("Hello, world!"));
699 /// ```
700 pub fn serialize(&self) -> String {
701 let mut s = String::new();
702
703 for subtitle in &self.subtitles {
704 let _ = writeln!(&mut s, "{}", subtitle.seq);
705 let _ = writeln!(&mut s, "{} --> {}", subtitle.start, subtitle.end);
706 let _ = writeln!(&mut s, "{}\n", subtitle.text);
707 }
708
709 s
710 }
711}
712
713/// Consuming iterator implementation for `Srt`.
714///
715/// This allows using `Srt` directly in a `for` loop, consuming the
716/// collection and yielding owned [`Subtitle`] values.
717///
718/// # Example
719///
720/// ```
721/// use skrt::Srt;
722///
723/// let data = "1\n00:00:00,000 --> 00:00:01,000\nHello\n\n2\n00:00:02,000 --> 00:00:03,000\nWorld\n\n";
724/// let srt = Srt::try_parse(data).unwrap();
725///
726/// for subtitle in srt {
727/// println!("{}: {}", subtitle.seq(), subtitle.text());
728/// }
729/// ```
730impl<'a> IntoIterator for Srt<'a> {
731 type Item = Subtitle<'a>;
732 type IntoIter = std::vec::IntoIter<Subtitle<'a>>;
733
734 fn into_iter(self) -> Self::IntoIter {
735 self.subtitles.into_iter()
736 }
737}
738
739impl<'a, 'b> IntoIterator for &'b Srt<'a> {
740 type Item = &'b Subtitle<'a>;
741 type IntoIter = std::slice::Iter<'b, Subtitle<'a>>;
742
743 fn into_iter(self) -> Self::IntoIter {
744 self.iter()
745 }
746}
747
748impl<'a, 'b> IntoIterator for &'b mut Srt<'a> {
749 type Item = &'b mut Subtitle<'a>;
750 type IntoIter = std::slice::IterMut<'b, Subtitle<'a>>;
751
752 fn into_iter(self) -> Self::IntoIter {
753 self.iter_mut()
754 }
755}
756
757fn parse_time(s: &str, position: usize) -> Result<Timestamp> {
758 let b = s.as_bytes();
759 if b.len() < 12 {
760 return Err(SrtError::UnexpectedEof);
761 }
762
763 let mut valid = b[0].is_ascii_digit();
764 valid &= b[1].is_ascii_digit();
765 valid &= matches!(b[2], b':');
766 valid &= matches!(b[3], b'0'..=b'5');
767 valid &= b[4].is_ascii_digit();
768 valid &= matches!(b[5], b':');
769 valid &= matches!(b[6], b'0'..=b'5');
770 valid &= b[7].is_ascii_digit();
771 valid &= matches!(b[8], b',');
772 valid &= b[9].is_ascii_digit();
773 valid &= b[10].is_ascii_digit();
774 valid &= b[11].is_ascii_digit();
775 if !valid {
776 return Err(SrtError::InvalidTimestamp { position });
777 }
778
779 Ok(Timestamp {
780 hours: (b[0] as u16 - b'0' as u16) * 10 + (b[1] as u16 - b'0' as u16),
781 minutes: (b[3] as u16 - b'0' as u16) * 10 + (b[4] as u16 - b'0' as u16),
782 seconds: (b[6] as u16 - b'0' as u16) * 10 + (b[7] as u16 - b'0' as u16),
783 milliseconds: (b[9] as u16 - b'0' as u16) * 100
784 + (b[10] as u16 - b'0' as u16) * 10
785 + (b[11] as u16 - b'0' as u16),
786 })
787}
788
789fn newline_discard(s: &str, position: usize) -> Result<usize> {
790 let mut discard = 0;
791 let mut it = s.chars();
792 while let Some(c) = it.next() {
793 match c {
794 ' ' | '\t' => discard += 1,
795 '\r' => {
796 if let Some('\n') = it.next() {
797 discard += 2;
798 break;
799 }
800 return Err(SrtError::ExpectedNewline {
801 position: position + discard,
802 });
803 }
804 '\n' => {
805 discard += 1;
806 break;
807 }
808 _ => {
809 return Err(SrtError::ExpectedNewline {
810 position: position + discard,
811 });
812 }
813 }
814 }
815 Ok(discard)
816}
817
818fn strip_prefix_bom(s: &str) -> &str {
819 s.strip_prefix("\u{feff}").unwrap_or(s)
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
825
826 #[test]
827 fn parse_empty_input() {
828 let srt = Srt::try_parse("").unwrap();
829 assert!(srt.subtitles.is_empty());
830 }
831
832 #[test]
833 fn parse_with_bom() {
834 let data = "\u{feff}1\n00:00:00,000 --> 00:00:01,000\nHello\n\n";
835 let srt = Srt::try_parse(data).unwrap();
836 assert_eq!(1, srt.subtitles.len());
837 }
838
839 #[test]
840 fn parse_crlf_line_endings() {
841 let data = "1\r\n00:00:00,000 --> 00:00:01,000\r\nHello\r\n\r\n";
842 let srt = Srt::try_parse(data).unwrap();
843 assert_eq!(1, srt.subtitles.len());
844 assert_eq!("Hello", srt.subtitles[0].text);
845 }
846
847 #[test]
848 fn parse_single_subtitle() {
849 let data = "1\n00:00:00,000 --> 00:00:01,000\nSingle line\n\n";
850 let srt = Srt::try_parse(data).unwrap();
851 assert_eq!(1, srt.subtitles.len());
852 assert_eq!("Single line", srt.subtitles[0].text);
853 }
854
855 #[test]
856 fn parse_multiline_text() {
857 let data = "1\n00:00:00,000 --> 00:00:01,000\nLine 1\nLine 2\nLine 3\n\n";
858 let srt = Srt::try_parse(data).unwrap();
859 assert_eq!("Line 1\nLine 2\nLine 3", srt.subtitles[0].text);
860 }
861
862 #[test]
863 fn parse_trailing_whitespace_after_timestamp() {
864 let data = "1\n00:00:00,000 --> 00:00:01,000 \nHello\n\n";
865 let srt = Srt::try_parse(data).unwrap();
866 assert_eq!(1, srt.subtitles.len());
867 }
868
869 #[test]
870 fn parse_no_trailing_newline() {
871 // File ends without double newline after last subtitle
872 let data = "1\n00:00:00,000 --> 00:00:01,000\nHello";
873 let srt = Srt::try_parse(data).unwrap();
874 assert_eq!(1, srt.subtitles.len());
875 assert_eq!("Hello", srt.subtitles[0].text);
876 }
877
878 #[test]
879 fn parse_error_invalid_sequence_number() {
880 let data = "abc\n00:00:00,000 --> 00:00:01,000\nHello\n\n";
881 let err = Srt::try_parse(data).unwrap_err();
882 assert!(matches!(err, SrtError::InvalidSequenceNumber { .. }));
883 }
884
885 #[test]
886 fn parse_error_missing_sequence_number() {
887 let data = "\n00:00:00,000 --> 00:00:01,000\nHello\n\n";
888 let err = Srt::try_parse(data).unwrap_err();
889 assert!(matches!(err, SrtError::InvalidSequenceNumber { .. }));
890 }
891
892 #[test]
893 fn parse_error_invalid_timestamp_format() {
894 let data = "1\n0:00:00,000 --> 00:00:01,000\nHello\n\n";
895 let err = Srt::try_parse(data).unwrap_err();
896 assert!(matches!(err, SrtError::InvalidTimestamp { .. }));
897 }
898
899 #[test]
900 fn parse_error_invalid_minutes() {
901 let data = "1\n00:60:00,000 --> 00:00:01,000\nHello\n\n";
902 let err = Srt::try_parse(data).unwrap_err();
903 assert!(matches!(err, SrtError::InvalidTimestamp { .. }));
904 }
905
906 #[test]
907 fn parse_error_invalid_seconds() {
908 let data = "1\n00:00:60,000 --> 00:00:01,000\nHello\n\n";
909 let err = Srt::try_parse(data).unwrap_err();
910 assert!(matches!(err, SrtError::InvalidTimestamp { .. }));
911 }
912
913 #[test]
914 fn parse_error_missing_separator() {
915 let data = "1\n00:00:00,000 -> 00:00:01,000\nHello\n\n";
916 let err = Srt::try_parse(data).unwrap_err();
917 assert!(matches!(err, SrtError::ExpectedTimeSeparator { .. }));
918 }
919
920 #[test]
921 fn parse_error_truncated_input() {
922 let data = "1\n00:00:00,000 --> 00:00";
923 let err = Srt::try_parse(data).unwrap_err();
924 assert!(matches!(err, SrtError::UnexpectedEof));
925 }
926
927 #[test]
928 fn parse_error_position_is_accurate() {
929 let data = "1\n00:00:00,000 --> 00:XX:01,000\nHello\n\n";
930 let err = Srt::try_parse(data).unwrap_err();
931 // "1\n00:00:00,000 --> " ; 19 bytes
932 assert!(matches!(err, SrtError::InvalidTimestamp { position: 19 }));
933 }
934
935 #[test]
936 fn srt_new_is_empty() {
937 let srt = Srt::new();
938 assert!(srt.subtitles.is_empty());
939 }
940
941 #[test]
942 fn srt_add_subtitle() {
943 let mut srt = Srt::new();
944 srt.add_subtitle(
945 Timestamp::from_millis(0).unwrap(),
946 Timestamp::from_millis(1000).unwrap(),
947 "Hello".into(),
948 );
949 srt.add_subtitle(
950 Timestamp::from_millis(1000).unwrap(),
951 Timestamp::from_millis(2000).unwrap(),
952 "World".into(),
953 );
954
955 assert_eq!(2, srt.subtitles.len());
956 assert_eq!(1, srt.subtitles[0].seq);
957 assert_eq!(2, srt.subtitles[1].seq);
958 }
959
960 #[test]
961 fn srt_serialize_roundtrip() {
962 let mut srt = Srt::new();
963 srt.add_subtitle(
964 Timestamp::from_millis(500).unwrap(),
965 Timestamp::from_millis(1500).unwrap(),
966 "Test subtitle".into(),
967 );
968
969 let serialized = srt.serialize();
970 let parsed = Srt::try_parse(&serialized).unwrap();
971
972 assert_eq!(srt, parsed);
973 }
974
975 #[test]
976 fn timestamp_zero() {
977 let ts = Timestamp::default();
978 assert_eq!(0, ts.to_millis());
979 assert_eq!("00:00:00,000", ts.to_string());
980 }
981
982 #[test]
983 fn timestamp_max_valid() {
984 let ts = Timestamp::from_millis(359_999_999).unwrap(); // 99:59:59,999
985 assert_eq!(99, ts.hours);
986 assert_eq!(59, ts.minutes);
987 assert_eq!(59, ts.seconds);
988 assert_eq!(999, ts.milliseconds);
989 assert_eq!("99:59:59,999", ts.to_string());
990 }
991
992 #[test]
993 fn timestamp_just_over_max() {
994 let result = Timestamp::from_millis(360_000_000); // 100:00:00,000
995 assert!(matches!(result, Err(SrtError::TimestampOutOfRange)));
996 }
997
998 #[test]
999 fn timestamp_one_millisecond() {
1000 let ts = Timestamp::from_millis(1).unwrap();
1001 assert_eq!(0, ts.hours);
1002 assert_eq!(0, ts.minutes);
1003 assert_eq!(0, ts.seconds);
1004 assert_eq!(1, ts.milliseconds);
1005 }
1006
1007 #[test]
1008 fn timestamp_one_second() {
1009 let ts = Timestamp::from_millis(1000).unwrap();
1010 assert_eq!(1, ts.seconds);
1011 assert_eq!(0, ts.milliseconds);
1012 }
1013
1014 #[test]
1015 fn timestamp_one_minute() {
1016 let ts = Timestamp::from_millis(60_000).unwrap();
1017 assert_eq!(1, ts.minutes);
1018 assert_eq!(0, ts.seconds);
1019 }
1020
1021 #[test]
1022 fn timestamp_one_hour() {
1023 let ts = Timestamp::from_millis(3_600_000).unwrap();
1024 assert_eq!(1, ts.hours);
1025 assert_eq!(0, ts.minutes);
1026 }
1027
1028 #[test]
1029 fn timestamp_shift_to_zero() {
1030 let ts = Timestamp::from_millis(1000).unwrap();
1031 let shifted = ts.shift_millis(-1000).unwrap();
1032 assert_eq!(0, shifted.to_millis());
1033 }
1034
1035 #[test]
1036 fn timestamp_shift_negative_result() {
1037 let ts = Timestamp::from_millis(1000).unwrap();
1038 let err = ts.shift_millis(-1001).unwrap_err();
1039 assert!(matches!(err, SrtError::NegativeTimestamp));
1040 }
1041
1042 #[test]
1043 fn timestamp_shift_overflow() {
1044 let ts = Timestamp::from_millis(359_999_999).unwrap();
1045 let err = ts.shift_millis(1).unwrap_err();
1046 assert!(matches!(err, SrtError::TimestampOutOfRange));
1047 }
1048
1049 #[test]
1050 fn timestamp_from_millis() {
1051 let ts = Timestamp::from_millis(177428182).unwrap();
1052
1053 assert_eq!(
1054 Timestamp {
1055 hours: 49,
1056 minutes: 17,
1057 seconds: 8,
1058 milliseconds: 182
1059 },
1060 ts
1061 );
1062 }
1063
1064 #[test]
1065 fn timestamp_from_millis_overflow() {
1066 let ts = Timestamp::from_millis(9999999999999999);
1067 assert!(ts.is_err());
1068 }
1069
1070 #[test]
1071 fn timestamp_to_millis() {
1072 let ts = Timestamp {
1073 hours: 49,
1074 minutes: 17,
1075 seconds: 8,
1076 milliseconds: 182,
1077 };
1078
1079 assert_eq!(177428182, ts.to_millis());
1080 }
1081
1082 #[test]
1083 fn timestamp_shift_millis() {
1084 let t1 = Timestamp::from_millis(12345).unwrap();
1085
1086 assert_eq!(12346, t1.shift_millis(1).unwrap().to_millis());
1087 assert_eq!(12344, t1.shift_millis(-1).unwrap().to_millis());
1088 assert_eq!(0, t1.shift_millis(-12345).unwrap().to_millis());
1089 assert!(t1.shift_millis(-12346).is_err());
1090 assert!(t1.shift_millis(9999999999999999).is_err());
1091 assert!(t1.shift_millis(i64::MAX).is_err());
1092 }
1093
1094 #[test]
1095 fn timestamp_ordering() {
1096 assert!(Timestamp::from_millis(0).unwrap() < Timestamp::from_millis(1).unwrap());
1097 assert!(Timestamp::from_millis(1).unwrap() > Timestamp::from_millis(0).unwrap());
1098 assert!(Timestamp::from_millis(1).unwrap() == Timestamp::from_millis(1).unwrap());
1099 assert!(
1100 Timestamp::from_millis(1234567).unwrap() < Timestamp::from_millis(1234568).unwrap()
1101 );
1102 assert!(
1103 Timestamp::from_millis(1234568).unwrap() > Timestamp::from_millis(1234567).unwrap()
1104 );
1105 assert!(
1106 Timestamp::from_millis(1234568).unwrap() == Timestamp::from_millis(1234568).unwrap()
1107 );
1108 }
1109
1110 #[test]
1111 fn timestamp_display() {
1112 assert_eq!(
1113 "00:00:00,000",
1114 Timestamp::from_millis(0).unwrap().to_string()
1115 );
1116 assert_eq!(
1117 "00:00:00,001",
1118 Timestamp::from_millis(1).unwrap().to_string()
1119 );
1120 assert_eq!(
1121 "49:17:08,182",
1122 Timestamp::from_millis(177428182).unwrap().to_string()
1123 );
1124 }
1125
1126 #[test]
1127 fn error_is_error_trait() {
1128 fn assert_error<E: std::error::Error>() {}
1129 assert_error::<SrtError>();
1130 }
1131}