1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub struct BidiLevel(u8);
12
13impl BidiLevel {
14 #[must_use]
16 pub const fn new(level: u8) -> Self {
17 Self(level)
18 }
19
20 #[must_use]
22 pub const fn ltr() -> Self {
23 Self(0)
24 }
25
26 #[must_use]
28 pub const fn rtl() -> Self {
29 Self(1)
30 }
31
32 #[must_use]
34 pub const fn is_rtl(&self) -> bool {
35 self.0 % 2 == 1
36 }
37
38 #[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#[derive(Clone, Debug)]
53pub struct TextLine {
54 pub glyphs: Vec<PositionedGlyph>,
56 pub width: f32,
58 pub height: f32,
60 pub baseline: f32,
62}
63
64#[derive(Clone, Debug)]
66pub struct PositionedGlyph {
67 pub c: char,
69 pub x: f32,
71 pub y: f32,
73 pub width: usize,
75 pub height: usize,
77 pub bitmap: Vec<u8>,
79 pub bidi_level: BidiLevel,
81}
82
83#[derive(Clone, Debug)]
85pub struct TextLayout {
86 pub lines: Vec<TextLine>,
88 pub width: f32,
90 pub height: f32,
92}
93
94impl TextLayout {
95 #[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 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 #[must_use]
114 pub const fn bounds(&self) -> (f32, f32) {
115 (self.width, self.height)
116 }
117
118 #[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
131pub struct TextLayoutEngine {
133 glyph_cache: GlyphCache,
134 simple_layout: SimpleLayoutEngine,
135}
136
137impl TextLayoutEngine {
138 #[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 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 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 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 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 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 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 if !current_line.is_empty() && (glyph_pos.y - current_y).abs() > line_height / 2.0 {
206 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 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(), });
231
232 line_width = line_width.max(glyph_pos.x + glyph_pos.width);
233 current_y = glyph_pos.y;
234 }
235
236 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 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 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 #[must_use]
283 pub fn glyph_cache(&self) -> &GlyphCache {
284 &self.glyph_cache
285 }
286
287 pub fn glyph_cache_mut(&mut self) -> &mut GlyphCache {
289 &mut self.glyph_cache
290 }
291}
292
293pub struct WordWrapper {
295 max_width: f32,
296}
297
298impl WordWrapper {
299 #[must_use]
301 pub const fn new(max_width: f32) -> Self {
302 Self { max_width }
303 }
304
305 #[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 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#[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#[must_use]
366pub fn decode_html_entities(text: &str) -> String {
367 text.replace("<", "<")
368 .replace(">", ">")
369 .replace("&", "&")
370 .replace(""", "\"")
371 .replace("'", "'")
372 .replace(" ", " ")
373}
374
375#[cfg(test)]
380mod bidi_tests {
381 use unicode_bidi::BidiInfo;
382
383 #[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 assert!(
397 !para.level.is_rtl(),
398 "paragraph base direction should be LTR when first strong char is Latin"
399 );
400 }
401
402 #[test]
405 fn test_bidi_mixed_arabic_latin_visual_order() {
406 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 let reordered: String = bidi.reorder_line(para, para.range.clone()).to_string();
414
415 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 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 #[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 #[test]
454 fn test_bidi_pure_rtl_reversal() {
455 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 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 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 #[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 #[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}