Skip to main content

libbitsub_core/vobsub/
idx_parser.rs

1//! VobSub IDX file parser.
2//!
3//! The IDX file contains timing information, palette data, and metadata.
4
5use crate::utils::rgb_to_rgba;
6
7/// VobSub palette (16 RGBA colors).
8#[derive(Debug, Clone)]
9pub struct VobSubPalette {
10    /// 16 RGBA colors (packed as u32 in little-endian: R, G, B, A bytes)
11    pub rgba: [u32; 16],
12}
13
14impl Default for VobSubPalette {
15    fn default() -> Self {
16        // Default grayscale palette
17        Self {
18            rgba: [
19                0x00000000, // Transparent
20                0xFFFFFFFF, // White
21                0xFF000000, // Black (with alpha)
22                0xFF808080, // Gray
23                0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000,
24                0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000, 0xFF000000,
25            ],
26        }
27    }
28}
29
30/// VobSub timestamp entry.
31#[derive(Debug, Clone, Copy)]
32pub struct VobSubTimestamp {
33    /// Timestamp in milliseconds
34    pub timestamp_ms: u32,
35    /// File position in the .sub file
36    pub file_position: u64,
37}
38
39/// VobSub metadata.
40#[derive(Debug, Clone)]
41pub struct VobSubMetadata {
42    /// Video width
43    pub width: u16,
44    /// Video height
45    pub height: u16,
46    /// Language code (if specified)
47    pub language: Option<String>,
48    /// Subtitle track ID
49    pub id: Option<String>,
50}
51
52impl Default for VobSubMetadata {
53    fn default() -> Self {
54        Self {
55            width: 720,
56            height: 480,
57            language: None,
58            id: None,
59        }
60    }
61}
62
63/// Parsed IDX file data.
64#[derive(Debug, Clone)]
65pub struct IdxParseResult {
66    pub palette: VobSubPalette,
67    pub timestamps: Vec<VobSubTimestamp>,
68    pub metadata: VobSubMetadata,
69}
70
71/// Parse a VobSub IDX file.
72pub fn parse_idx(idx_content: &str) -> IdxParseResult {
73    let mut result = IdxParseResult {
74        palette: VobSubPalette::default(),
75        timestamps: Vec::new(),
76        metadata: VobSubMetadata::default(),
77    };
78
79    for line in idx_content.lines() {
80        let trimmed = line.trim();
81        if trimmed.is_empty() || trimmed.starts_with('#') {
82            continue;
83        }
84
85        // Parse size
86        if let Some(rest) = trimmed.strip_prefix("size:") {
87            if let Some((w_str, h_str)) = rest.trim().split_once('x')
88                && let (Ok(w), Ok(h)) = (w_str.trim().parse::<u16>(), h_str.trim().parse::<u16>())
89            {
90                result.metadata.width = w;
91                result.metadata.height = h;
92            }
93            continue;
94        }
95
96        // Parse palette
97        if let Some(rest) = trimmed.strip_prefix("palette:") {
98            let colors: Vec<&str> = rest.split(',').map(|s| s.trim()).collect();
99            for (i, color_hex) in colors.iter().enumerate().take(16) {
100                let hex = color_hex.trim_start_matches('#');
101                if hex.len() == 6
102                    && let Ok(rgb) = u32::from_str_radix(hex, 16)
103                {
104                    let r = ((rgb >> 16) & 0xFF) as u8;
105                    let g = ((rgb >> 8) & 0xFF) as u8;
106                    let b = (rgb & 0xFF) as u8;
107                    result.palette.rgba[i] = rgb_to_rgba(r, g, b, 255);
108                }
109            }
110            continue;
111        }
112
113        // Parse language ID
114        if let Some(rest) = trimmed.strip_prefix("id:") {
115            let parts: Vec<&str> = rest.split(',').collect();
116            if !parts.is_empty() {
117                result.metadata.language = Some(parts[0].trim().to_string());
118            }
119            if let Some(idx_part) = parts.get(1)
120                && let Some(idx_str) = idx_part.trim().strip_prefix("index:")
121            {
122                result.metadata.id = Some(idx_str.trim().to_string());
123            }
124            continue;
125        }
126
127        // Parse timestamp entries
128        // Format: timestamp: HH:MM:SS:mmm, filepos: XXXXXXXX
129        if let Some(rest) = trimmed.strip_prefix("timestamp:")
130            && let Some((time_part, filepos_part)) = rest.split_once(',')
131        {
132            let time_str = time_part.trim();
133            let filepos_str = filepos_part
134                .trim()
135                .strip_prefix("filepos:")
136                .map(|s| s.trim());
137
138            if let Some(filepos_hex) = filepos_str {
139                // Parse timestamp HH:MM:SS:mmm
140                let parts: Vec<&str> = time_str.split(':').collect();
141                if parts.len() == 4
142                    && let (Ok(h), Ok(m), Ok(s), Ok(ms)) = (
143                        parts[0].parse::<u32>(),
144                        parts[1].parse::<u32>(),
145                        parts[2].parse::<u32>(),
146                        parts[3].parse::<u32>(),
147                    )
148                {
149                    let timestamp_ms = h * 3600000 + m * 60000 + s * 1000 + ms;
150
151                    // Parse file position (hex)
152                    if let Ok(file_position) = u64::from_str_radix(filepos_hex, 16) {
153                        result.timestamps.push(VobSubTimestamp {
154                            timestamp_ms,
155                            file_position,
156                        });
157                    }
158                }
159            }
160        }
161    }
162
163    result
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_parse_idx_basic() {
172        let idx = r#"
173# VobSub index file
174size: 720x480
175palette: 000000, ffffff, 808080, 404040, 000000, 000000, 000000, 000000, 000000, 000000, 000000, 000000, 000000, 000000, 000000, 000000
176id: en, index: 0
177timestamp: 00:00:01:000, filepos: 00000000
178timestamp: 00:00:05:500, filepos: 00001000
179"#;
180
181        let result = parse_idx(idx);
182
183        assert_eq!(result.metadata.width, 720);
184        assert_eq!(result.metadata.height, 480);
185        assert_eq!(result.metadata.language, Some("en".to_string()));
186        assert_eq!(result.timestamps.len(), 2);
187        assert_eq!(result.timestamps[0].timestamp_ms, 1000);
188        assert_eq!(result.timestamps[1].timestamp_ms, 5500);
189    }
190
191    #[test]
192    fn test_parse_real_vobsub_durations() {
193        use crate::vobsub::parse_subtitle_packet;
194
195        let idx_content = include_str!("../testfiles/vobsub.idx");
196        let sub_data = include_bytes!("../testfiles/vobsub.sub");
197
198        let idx = parse_idx(idx_content);
199
200        println!("\n=== VobSub Duration Analysis ===");
201        println!("Total timestamps: {}", idx.timestamps.len());
202
203        // Parse first 10 packets and check their durations
204        for (i, ts) in idx.timestamps.iter().take(10).enumerate() {
205            let next_ts = idx.timestamps.get(i + 1);
206            let gap_to_next = next_ts.map(|n| n.timestamp_ms - ts.timestamp_ms);
207
208            if let Some((packet, _)) =
209                parse_subtitle_packet(sub_data, ts.file_position as usize, &idx.palette)
210            {
211                // Now we should get real durations from control sequences
212                println!(
213                    "Sub {}: start={}ms, ctrl_dur={}ms, gap={}ms",
214                    i,
215                    ts.timestamp_ms,
216                    packet.duration_ms,
217                    gap_to_next.unwrap_or(0)
218                );
219
220                // Duration from control sequence should be less than gap to next subtitle
221                // (subtitle ends before next one starts)
222                if let Some(gap) = gap_to_next {
223                    assert!(
224                        packet.duration_ms <= gap || packet.duration_ms == 5000,
225                        "Duration {} should be <= gap {} (or default 5000)",
226                        packet.duration_ms,
227                        gap
228                    );
229                }
230            } else {
231                println!(
232                    "Subtitle {}: FAILED TO PARSE at offset 0x{:X}",
233                    i, ts.file_position
234                );
235            }
236        }
237
238        println!("\n=== Durations are now correctly parsed from control sequences ===");
239    }
240}