Skip to main content

ass_core/analysis/events/
line_breaks.rs

1//! Line break handling with type preservation
2//!
3//! This module provides enhanced line break processing that preserves
4//! the distinction between hard (\N) and soft (\n) line breaks.
5
6use alloc::{string::String, vec::Vec};
7
8/// Type of line break in ASS text
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum LineBreakType {
11    /// Hard line break (\N) - forces a new line
12    Hard,
13    /// Soft line break (\n) - allows wrapping
14    Soft,
15}
16
17/// Line break information in processed text
18#[derive(Debug, Clone)]
19pub struct LineBreakInfo {
20    /// Position in the plain text where the break occurs
21    pub position: usize,
22    /// Type of line break
23    pub break_type: LineBreakType,
24}
25
26/// Enhanced text with line break preservation
27#[derive(Debug, Clone)]
28pub struct TextWithLineBreaks {
29    /// Plain text with line breaks converted to newlines
30    pub text: String,
31    /// Information about each line break's type and position
32    pub line_breaks: Vec<LineBreakInfo>,
33    /// Non-breaking space positions
34    pub nbsp_positions: Vec<usize>,
35}
36
37impl TextWithLineBreaks {
38    /// Process text preserving line break types
39    #[must_use]
40    pub fn from_text(text: &str, drawing_mode: bool) -> Self {
41        let mut plain_text = String::new();
42        let mut line_breaks = Vec::new();
43        let mut nbsp_positions = Vec::new();
44
45        let mut chars = text.chars().peekable();
46
47        while let Some(ch) = chars.next() {
48            if ch == '\\' {
49                if let Some(&next_ch) = chars.peek() {
50                    match next_ch {
51                        'N' => {
52                            chars.next(); // consume 'N'
53                            if !drawing_mode {
54                                line_breaks.push(LineBreakInfo {
55                                    position: plain_text.len(),
56                                    break_type: LineBreakType::Hard,
57                                });
58                                plain_text.push('\n');
59                            }
60                        }
61                        'n' => {
62                            chars.next(); // consume 'n'
63                            if !drawing_mode {
64                                line_breaks.push(LineBreakInfo {
65                                    position: plain_text.len(),
66                                    break_type: LineBreakType::Soft,
67                                });
68                                plain_text.push('\n');
69                            }
70                        }
71                        'h' => {
72                            chars.next(); // consume 'h'
73                            if !drawing_mode {
74                                nbsp_positions.push(plain_text.len());
75                                plain_text.push('\u{00A0}'); // Non-breaking space
76                            }
77                        }
78                        _ => {
79                            // Not a special sequence, keep both characters
80                            plain_text.push(ch);
81                            plain_text.push(next_ch);
82                            chars.next();
83                        }
84                    }
85                } else {
86                    // Backslash at end of string
87                    plain_text.push(ch);
88                }
89            } else {
90                plain_text.push(ch);
91            }
92        }
93
94        Self {
95            text: plain_text,
96            line_breaks,
97            nbsp_positions,
98        }
99    }
100
101    /// Get the type of line break at a given position
102    #[must_use]
103    pub fn get_break_type_at(&self, position: usize) -> Option<LineBreakType> {
104        self.line_breaks
105            .iter()
106            .find(|lb| lb.position == position)
107            .map(|lb| lb.break_type)
108    }
109
110    /// Check if a position has a non-breaking space
111    #[must_use]
112    pub fn is_nbsp_at(&self, position: usize) -> bool {
113        self.nbsp_positions.contains(&position)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn test_hard_line_break() {
123        let text = r"Line 1\NLine 2";
124        let processed = TextWithLineBreaks::from_text(text, false);
125
126        assert_eq!(processed.text, "Line 1\nLine 2");
127        assert_eq!(processed.line_breaks.len(), 1);
128        assert_eq!(processed.line_breaks[0].break_type, LineBreakType::Hard);
129        assert_eq!(processed.line_breaks[0].position, 6);
130    }
131
132    #[test]
133    fn test_soft_line_break() {
134        let text = r"Line 1\nLine 2";
135        let processed = TextWithLineBreaks::from_text(text, false);
136
137        assert_eq!(processed.text, "Line 1\nLine 2");
138        assert_eq!(processed.line_breaks.len(), 1);
139        assert_eq!(processed.line_breaks[0].break_type, LineBreakType::Soft);
140        assert_eq!(processed.line_breaks[0].position, 6);
141    }
142
143    #[test]
144    fn test_mixed_line_breaks() {
145        let text = r"Line 1\NLine 2\nLine 3";
146        let processed = TextWithLineBreaks::from_text(text, false);
147
148        assert_eq!(processed.text, "Line 1\nLine 2\nLine 3");
149        assert_eq!(processed.line_breaks.len(), 2);
150        assert_eq!(processed.line_breaks[0].break_type, LineBreakType::Hard);
151        assert_eq!(processed.line_breaks[1].break_type, LineBreakType::Soft);
152    }
153
154    #[test]
155    fn test_non_breaking_space() {
156        let text = r"Word1\hWord2";
157        let processed = TextWithLineBreaks::from_text(text, false);
158
159        assert_eq!(processed.text, "Word1\u{00A0}Word2");
160        assert_eq!(processed.nbsp_positions.len(), 1);
161        assert_eq!(processed.nbsp_positions[0], 5);
162    }
163
164    #[test]
165    fn test_drawing_mode_ignores_special() {
166        let text = r"Draw\NCommands\nHere\hIgnored";
167        let processed = TextWithLineBreaks::from_text(text, true);
168
169        assert_eq!(processed.text, "DrawCommandsHereIgnored");
170        assert_eq!(processed.line_breaks.len(), 0);
171        assert_eq!(processed.nbsp_positions.len(), 0);
172    }
173}