Skip to main content

oximedia_subtitle/
text.rs

1//! Text layout and bidirectional text support.
2
3use crate::font::{Font, GlyphCache, SimpleLayoutEngine};
4use crate::style::{Alignment, SubtitleStyle};
5use crate::{SubtitleError, SubtitleResult};
6use unicode_bidi::{BidiInfo, Level};
7use unicode_segmentation::UnicodeSegmentation;
8
9/// Bidirectional text level.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub struct BidiLevel(u8);
12
13impl BidiLevel {
14    /// Create a new bidi level.
15    #[must_use]
16    pub const fn new(level: u8) -> Self {
17        Self(level)
18    }
19
20    /// Left-to-right.
21    #[must_use]
22    pub const fn ltr() -> Self {
23        Self(0)
24    }
25
26    /// Right-to-left.
27    #[must_use]
28    pub const fn rtl() -> Self {
29        Self(1)
30    }
31
32    /// Check if this is RTL.
33    #[must_use]
34    pub const fn is_rtl(&self) -> bool {
35        self.0 % 2 == 1
36    }
37
38    /// Get the underlying level value.
39    #[must_use]
40    pub const fn value(&self) -> u8 {
41        self.0
42    }
43}
44
45impl From<Level> for BidiLevel {
46    fn from(level: Level) -> Self {
47        Self(level.number())
48    }
49}
50
51/// A line of laid out text with glyph positions.
52#[derive(Clone, Debug)]
53pub struct TextLine {
54    /// Glyphs in this line.
55    pub glyphs: Vec<PositionedGlyph>,
56    /// Line width in pixels.
57    pub width: f32,
58    /// Line height in pixels.
59    pub height: f32,
60    /// Baseline Y offset.
61    pub baseline: f32,
62}
63
64/// A positioned glyph with all rendering information.
65#[derive(Clone, Debug)]
66pub struct PositionedGlyph {
67    /// Character.
68    pub c: char,
69    /// X position.
70    pub x: f32,
71    /// Y position.
72    pub y: f32,
73    /// Glyph bitmap width.
74    pub width: usize,
75    /// Glyph bitmap height.
76    pub height: usize,
77    /// Rasterized bitmap.
78    pub bitmap: Vec<u8>,
79    /// Bidirectional level.
80    pub bidi_level: BidiLevel,
81}
82
83/// Complete text layout with multiple lines.
84#[derive(Clone, Debug)]
85pub struct TextLayout {
86    /// Lines of text.
87    pub lines: Vec<TextLine>,
88    /// Total width.
89    pub width: f32,
90    /// Total height.
91    pub height: f32,
92}
93
94impl TextLayout {
95    /// Create an empty layout.
96    #[must_use]
97    pub fn new() -> Self {
98        Self {
99            lines: Vec::new(),
100            width: 0.0,
101            height: 0.0,
102        }
103    }
104
105    /// Add a line.
106    pub fn add_line(&mut self, line: TextLine) {
107        self.width = self.width.max(line.width);
108        self.height += line.height;
109        self.lines.push(line);
110    }
111
112    /// Get bounds.
113    #[must_use]
114    pub const fn bounds(&self) -> (f32, f32) {
115        (self.width, self.height)
116    }
117
118    /// Check if layout is empty.
119    #[must_use]
120    pub fn is_empty(&self) -> bool {
121        self.lines.is_empty()
122    }
123}
124
125impl Default for TextLayout {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131/// Text layout engine with full Unicode and bidirectional text support.
132pub struct TextLayoutEngine {
133    glyph_cache: GlyphCache,
134    simple_layout: SimpleLayoutEngine,
135}
136
137impl TextLayoutEngine {
138    /// Create a new text layout engine.
139    #[must_use]
140    pub fn new(font: Font) -> Self {
141        Self {
142            glyph_cache: GlyphCache::new(font),
143            simple_layout: SimpleLayoutEngine::new(),
144        }
145    }
146
147    /// Layout text according to style.
148    ///
149    /// # Errors
150    ///
151    /// Returns error if text layout fails.
152    pub fn layout(
153        &mut self,
154        text: &str,
155        style: &SubtitleStyle,
156        max_width: u32,
157    ) -> SubtitleResult<TextLayout> {
158        if text.is_empty() {
159            return Ok(TextLayout::new());
160        }
161
162        // Handle bidirectional text
163        let bidi_info = BidiInfo::new(text, None);
164        let paragraph = &bidi_info.paragraphs[0];
165        let line_bidi = bidi_info.reorder_line(paragraph, paragraph.range.clone());
166
167        // Split into lines (handle explicit line breaks)
168        let mut layout = TextLayout::new();
169        let font_metrics = self.glyph_cache.font().metrics(style.font_size);
170
171        for line_text in text.lines() {
172            if line_text.is_empty() {
173                // Empty line - just add spacing
174                layout.add_line(TextLine {
175                    glyphs: Vec::new(),
176                    width: 0.0,
177                    height: font_metrics.new_line_size * style.line_spacing,
178                    baseline: font_metrics.ascent,
179                });
180                continue;
181            }
182
183            // Use simple layout for word wrapping if needed
184            let max_w = if max_width > 0 {
185                Some(max_width as f32)
186            } else {
187                None
188            };
189
190            let glyph_positions = self.simple_layout.layout_text(
191                self.glyph_cache.font(),
192                line_text,
193                style.font_size,
194                max_w,
195            );
196
197            // Group glyphs by line (in case of wrapping)
198            let mut current_line = Vec::new();
199            let mut current_y = 0.0;
200            let mut line_width = 0.0;
201            let line_height = font_metrics.new_line_size * style.line_spacing;
202
203            for glyph_pos in glyph_positions {
204                // Check if we moved to a new line
205                if !current_line.is_empty() && (glyph_pos.y - current_y).abs() > line_height / 2.0 {
206                    // Finish current line
207                    self.finish_line(
208                        &mut layout,
209                        current_line,
210                        line_width,
211                        line_height,
212                        font_metrics.ascent,
213                        style.alignment,
214                    );
215                    current_line = Vec::new();
216                    line_width = 0.0;
217                }
218
219                // Get cached glyph
220                let cached = self.glyph_cache.get_glyph(glyph_pos.c, style.font_size);
221
222                current_line.push(PositionedGlyph {
223                    c: glyph_pos.c,
224                    x: glyph_pos.x,
225                    y: glyph_pos.y,
226                    width: cached.width,
227                    height: cached.height,
228                    bitmap: cached.bitmap.clone(),
229                    bidi_level: BidiLevel::ltr(), // Simplified - would need proper bidi mapping
230                });
231
232                line_width = line_width.max(glyph_pos.x + glyph_pos.width);
233                current_y = glyph_pos.y;
234            }
235
236            // Finish last line
237            if !current_line.is_empty() {
238                self.finish_line(
239                    &mut layout,
240                    current_line,
241                    line_width,
242                    line_height,
243                    font_metrics.ascent,
244                    style.alignment,
245                );
246            }
247        }
248
249        Ok(layout)
250    }
251
252    /// Finish and add a line to the layout with proper alignment.
253    fn finish_line(
254        &self,
255        layout: &mut TextLayout,
256        mut glyphs: Vec<PositionedGlyph>,
257        width: f32,
258        height: f32,
259        baseline: f32,
260        alignment: Alignment,
261    ) {
262        // Apply horizontal alignment by adjusting X positions
263        let offset = match alignment {
264            Alignment::Left => 0.0,
265            Alignment::Center => -width / 2.0,
266            Alignment::Right => -width,
267        };
268
269        for glyph in &mut glyphs {
270            glyph.x += offset;
271        }
272
273        layout.add_line(TextLine {
274            glyphs,
275            width,
276            height,
277            baseline,
278        });
279    }
280
281    /// Get glyph cache.
282    #[must_use]
283    pub fn glyph_cache(&self) -> &GlyphCache {
284        &self.glyph_cache
285    }
286
287    /// Get mutable glyph cache.
288    pub fn glyph_cache_mut(&mut self) -> &mut GlyphCache {
289        &mut self.glyph_cache
290    }
291}
292
293/// Word wrapping utility.
294pub struct WordWrapper {
295    max_width: f32,
296}
297
298impl WordWrapper {
299    /// Create a new word wrapper.
300    #[must_use]
301    pub const fn new(max_width: f32) -> Self {
302        Self { max_width }
303    }
304
305    /// Wrap text into lines.
306    #[must_use]
307    pub fn wrap(&self, text: &str, measure: impl Fn(&str) -> f32) -> Vec<String> {
308        let mut lines = Vec::new();
309        let mut current_line = String::new();
310        let mut current_width = 0.0;
311
312        for word in text.unicode_words() {
313            let word_width = measure(word);
314            let space_width = measure(" ");
315
316            if current_line.is_empty() {
317                current_line.push_str(word);
318                current_width = word_width;
319            } else if current_width + space_width + word_width <= self.max_width {
320                current_line.push(' ');
321                current_line.push_str(word);
322                current_width += space_width + word_width;
323            } else {
324                // Start new line
325                lines.push(current_line);
326                current_line = word.to_string();
327                current_width = word_width;
328            }
329        }
330
331        if !current_line.is_empty() {
332            lines.push(current_line);
333        }
334
335        if lines.is_empty() {
336            lines.push(String::new());
337        }
338
339        lines
340    }
341}
342
343/// Strip HTML tags from text (basic implementation for SRT).
344#[must_use]
345pub fn strip_html_tags(text: &str) -> String {
346    let mut result = String::with_capacity(text.len());
347    let mut in_tag = false;
348
349    for c in text.chars() {
350        match c {
351            '<' => in_tag = true,
352            '>' => in_tag = false,
353            _ => {
354                if !in_tag {
355                    result.push(c);
356                }
357            }
358        }
359    }
360
361    result
362}
363
364/// Decode HTML entities (basic implementation).
365#[must_use]
366pub fn decode_html_entities(text: &str) -> String {
367    text.replace("&lt;", "<")
368        .replace("&gt;", ">")
369        .replace("&amp;", "&")
370        .replace("&quot;", "\"")
371        .replace("&apos;", "'")
372        .replace("&nbsp;", " ")
373}
374
375// ============================================================================
376// Bidirectional text tests
377// ============================================================================
378
379#[cfg(test)]
380mod bidi_tests {
381    use unicode_bidi::BidiInfo;
382
383    /// The mixed string starts with a Latin 'H' (strong LTR), so the paragraph
384    /// base direction must be detected as LTR (even level 0).
385    #[test]
386    fn test_bidi_paragraph_direction_ltr_first() {
387        let text = "Hello \u{0645}\u{0631}\u{062D}\u{0628}\u{0627} World";
388        let bidi = BidiInfo::new(text, None);
389        assert_eq!(
390            bidi.paragraphs.len(),
391            1,
392            "should form exactly one paragraph"
393        );
394        let para = &bidi.paragraphs[0];
395        // LTR paragraph: level is even (0).
396        assert!(
397            !para.level.is_rtl(),
398            "paragraph base direction should be LTR when first strong char is Latin"
399        );
400    }
401
402    /// Reorder the visual runs of the mixed string and verify that Arabic
403    /// characters appear between the two English word groups.
404    #[test]
405    fn test_bidi_mixed_arabic_latin_visual_order() {
406        // "Hello مرحبا World"
407        // In logical order: H e l l o   [arabic 5 chars]   W o r l d
408        let text = "Hello \u{0645}\u{0631}\u{062D}\u{0628}\u{0627} World";
409        let bidi = BidiInfo::new(text, None);
410        let para = &bidi.paragraphs[0];
411
412        // Reorder the whole paragraph into visual order.
413        let reordered: String = bidi.reorder_line(para, para.range.clone()).to_string();
414
415        // In visual (left-to-right display) order for an LTR paragraph:
416        // "Hello" comes first, then Arabic (which renders RTL internally),
417        // then "World" comes last. The reordered string must contain all three
418        // segments.
419        assert!(
420            reordered.contains("Hello"),
421            "reordered string must contain 'Hello'"
422        );
423        assert!(
424            reordered.contains("World"),
425            "reordered string must contain 'World'"
426        );
427        // Arabic characters must still be present.
428        let arabic_chars: String = reordered
429            .chars()
430            .filter(|c| ('\u{0600}'..='\u{06FF}').contains(c))
431            .collect();
432        assert_eq!(
433            arabic_chars.chars().count(),
434            5,
435            "all 5 Arabic characters must survive reordering"
436        );
437    }
438
439    /// Verify that a purely LTR string is unchanged after reordering.
440    #[test]
441    fn test_bidi_pure_ltr_unchanged() {
442        let text = "Hello World";
443        let bidi = BidiInfo::new(text, None);
444        let para = &bidi.paragraphs[0];
445        let reordered: String = bidi.reorder_line(para, para.range.clone()).to_string();
446        assert_eq!(
447            reordered, text,
448            "purely LTR text should be unchanged after reorder"
449        );
450    }
451
452    /// Verify that a purely RTL string is reversed by reorder_line.
453    #[test]
454    fn test_bidi_pure_rtl_reversal() {
455        // A string with only Arabic characters: "مرحبا" (Marhaba = Hello)
456        let text = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}";
457        let bidi = BidiInfo::new(text, None);
458        let para = &bidi.paragraphs[0];
459        // Paragraph level should be RTL (odd level).
460        assert!(para.level.is_rtl(), "purely Arabic paragraph should be RTL");
461        let reordered: String = bidi.reorder_line(para, para.range.clone()).to_string();
462        // All Arabic chars still present.
463        let count = reordered
464            .chars()
465            .filter(|c| ('\u{0600}'..='\u{06FF}').contains(c))
466            .count();
467        assert_eq!(count, 5, "all 5 Arabic chars present after RTL reorder");
468    }
469
470    /// BidiLevel wrapper correctly reports direction.
471    #[test]
472    fn test_bidi_level_wrapper() {
473        use super::BidiLevel;
474        assert!(!BidiLevel::ltr().is_rtl());
475        assert!(BidiLevel::rtl().is_rtl());
476        assert!(!BidiLevel::new(0).is_rtl());
477        assert!(BidiLevel::new(1).is_rtl());
478        assert!(!BidiLevel::new(2).is_rtl());
479        assert!(BidiLevel::new(3).is_rtl());
480    }
481
482    /// From&lt;unicode_bidi::Level&gt; conversion preserves the level number.
483    #[test]
484    fn test_bidi_level_from_unicode_bidi() {
485        use super::BidiLevel;
486        use unicode_bidi::Level;
487        let ul = Level::ltr();
488        let bl = BidiLevel::from(ul);
489        assert_eq!(bl.value(), ul.number());
490    }
491}