Skip to main content

rassa_layout/
lib.rs

1use rassa_core::{RassaError, RassaResult, Rect, ass};
2use rassa_fonts::{
3    FontMatch, FontProvider, FontQuery, font_match_supports_text, resolve_system_font_for_char,
4};
5use rassa_parse::{
6    ParsedDrawing, ParsedEvent, ParsedFade, ParsedKaraokeSpan, ParsedMovement, ParsedSpanStyle,
7    ParsedSpanTransform, ParsedStyle, ParsedTrack, ParsedVectorClip, parse_dialogue_text,
8};
9use rassa_shape::{GlyphInfo, ShapeEngine, ShapeRequest, ShapingMode};
10use rassa_unibreak::{LineBreakOpportunity, classify_line_breaks};
11use rassa_unicode::BidiDirection;
12
13#[derive(Clone, Debug, Default, PartialEq)]
14pub struct LayoutGlyphRun {
15    pub text: String,
16    pub direction: BidiDirection,
17    pub font_family: String,
18    pub font: FontMatch,
19    pub glyphs: Vec<GlyphInfo>,
20    pub width: f32,
21    pub style: ParsedSpanStyle,
22    pub transforms: Vec<ParsedSpanTransform>,
23    pub karaoke: Option<ParsedKaraokeSpan>,
24    pub drawing: Option<ParsedDrawing>,
25}
26
27#[derive(Clone, Debug, Default, PartialEq)]
28pub struct LayoutLine {
29    pub event_index: usize,
30    pub style_index: usize,
31    pub text: String,
32    pub direction: BidiDirection,
33    pub glyph_count: usize,
34    pub width: f32,
35    pub runs: Vec<LayoutGlyphRun>,
36}
37
38#[derive(Clone, Debug, Default, PartialEq)]
39pub struct LayoutEvent {
40    pub event_index: usize,
41    pub style_index: usize,
42    pub text: String,
43    pub font_family: String,
44    pub font: FontMatch,
45    pub alignment: i32,
46    pub justify: i32,
47    pub margin_l: i32,
48    pub margin_r: i32,
49    pub margin_v: i32,
50    pub position: Option<(i32, i32)>,
51    pub movement: Option<ParsedMovement>,
52    pub fade: Option<ParsedFade>,
53    pub clip_rect: Option<Rect>,
54    pub vector_clip: Option<ParsedVectorClip>,
55    pub inverse_clip: bool,
56    pub wrap_style: Option<i32>,
57    pub origin: Option<(i32, i32)>,
58    pub lines: Vec<LayoutLine>,
59}
60
61#[derive(Default)]
62pub struct LayoutEngine {
63    shaper: ShapeEngine,
64}
65
66impl LayoutEngine {
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    pub fn layout_track_event_with_mode<P: FontProvider>(
72        &self,
73        track: &ParsedTrack,
74        event_index: usize,
75        provider: &P,
76        shaping_mode: ShapingMode,
77    ) -> RassaResult<LayoutEvent> {
78        let event = track
79            .events
80            .get(event_index)
81            .ok_or_else(|| RassaError::new(format!("event index {event_index} out of range")))?;
82        let style_index = normalize_style_index(track, event);
83        let style = track
84            .styles
85            .get(style_index)
86            .unwrap_or(&track.styles[track.default_style as usize]);
87        let parsed_text = parse_dialogue_text(&event.text, style, &track.styles);
88        let font = provider.resolve(&FontQuery {
89            family: style.font_name.clone(),
90            style: None,
91        });
92        let explicit_lines = parsed_text
93            .lines
94            .iter()
95            .map(|line| {
96                layout_line_from_text(
97                    event_index,
98                    style_index,
99                    line,
100                    provider,
101                    &self.shaper,
102                    &track.language,
103                    shaping_mode,
104                )
105            })
106            .collect::<RassaResult<Vec<_>>>()?;
107        let wrap_style = parsed_text
108            .wrap_style
109            .unwrap_or(track.wrap_style)
110            .clamp(0, 3);
111        let alignment = parsed_text.alignment.unwrap_or(style.alignment);
112        let max_width = auto_wrap_width(track, event, style, parsed_text.position, alignment);
113        let lines = wrap_layout_lines(explicit_lines, max_width, wrap_style, &track.language)?;
114
115        Ok(LayoutEvent {
116            event_index,
117            style_index,
118            text: parsed_text
119                .lines
120                .iter()
121                .map(|line| line.text.as_str())
122                .collect::<Vec<_>>()
123                .join("\n"),
124            font_family: font.family.clone(),
125            font: font.clone(),
126            alignment: parsed_text.alignment.unwrap_or(style.alignment),
127            justify: normalize_justify(style.justify, style.alignment),
128            margin_l: resolve_margin(event.margin_l, style.margin_l),
129            margin_r: resolve_margin(event.margin_r, style.margin_r),
130            margin_v: resolve_margin(event.margin_v, style.margin_v),
131            position: parsed_text.position,
132            movement: parsed_text.movement,
133            fade: parsed_text.fade,
134            clip_rect: parsed_text.clip_rect,
135            vector_clip: parsed_text.vector_clip,
136            inverse_clip: parsed_text.inverse_clip,
137            wrap_style: parsed_text.wrap_style,
138            origin: parsed_text.origin,
139            lines,
140        })
141    }
142
143    pub fn layout_track_event<P: FontProvider>(
144        &self,
145        track: &ParsedTrack,
146        event_index: usize,
147        provider: &P,
148    ) -> RassaResult<LayoutEvent> {
149        self.layout_track_event_with_mode(track, event_index, provider, ShapingMode::Complex)
150    }
151}
152
153fn layout_line_from_text<P: FontProvider>(
154    event_index: usize,
155    style_index: usize,
156    line: &rassa_parse::ParsedTextLine,
157    provider: &P,
158    shaper: &ShapeEngine,
159    language: &str,
160    shaping_mode: ShapingMode,
161) -> RassaResult<LayoutLine> {
162    let mut runs = Vec::new();
163    let mut line_direction = BidiDirection::LeftToRight;
164    for span in &line.spans {
165        if span.text.is_empty() {
166            continue;
167        }
168        let font = provider.resolve(&FontQuery {
169            family: span.style.font_name.clone(),
170            style: font_style_name(&span.style),
171        });
172        if let Some(drawing) = &span.drawing {
173            let width = drawing
174                .bounds()
175                .map(|bounds| bounds.width() as f32 * span.style.scale_x.max(0.0) as f32)
176                .unwrap_or_default();
177            runs.push(LayoutGlyphRun {
178                text: span.text.clone(),
179                direction: line_direction,
180                font_family: font.family.clone(),
181                font: font.clone(),
182                glyphs: Vec::new(),
183                width,
184                style: span.style.clone(),
185                transforms: span.transforms.clone(),
186                karaoke: span.karaoke,
187                drawing: Some(drawing.clone()),
188            });
189            continue;
190        }
191        let shaped_chunks = split_text_by_font(
192            &span.text,
193            provider,
194            &span.style.font_name,
195            font_style_name(&span.style),
196        );
197        for (chunk_text, chunk_font) in shaped_chunks {
198            let shaped = shaper.shape_text(
199                provider,
200                &ShapeRequest::new(&chunk_text, &chunk_font.family)
201                    .with_style(chunk_font.style.clone().unwrap_or_default())
202                    .with_language(language)
203                    .with_font_size(span.style.font_size as f32)
204                    .with_mode(shaping_mode),
205            )?;
206            for shaped_run in shaped.runs {
207                line_direction = shaped_run.direction;
208                let run_font = shaped_run.font.clone();
209                runs.push(LayoutGlyphRun {
210                    text: shaped_run.text,
211                    direction: shaped_run.direction,
212                    font_family: run_font.family.clone(),
213                    font: run_font,
214                    width: text_run_width(&shaped_run.glyphs, &span.style),
215                    glyphs: shaped_run.glyphs,
216                    style: span.style.clone(),
217                    transforms: span.transforms.clone(),
218                    karaoke: span.karaoke,
219                    drawing: None,
220                });
221            }
222        }
223    }
224
225    let glyph_count = runs.iter().map(|run| run.glyphs.len()).sum();
226    let width = runs.iter().map(|run| run.width).sum();
227    Ok(LayoutLine {
228        event_index,
229        style_index,
230        text: line.text.clone(),
231        direction: line_direction,
232        glyph_count,
233        width,
234        runs,
235    })
236}
237
238fn auto_wrap_width(
239    track: &ParsedTrack,
240    event: &ParsedEvent,
241    style: &ParsedStyle,
242    _position: Option<(i32, i32)>,
243    _alignment: i32,
244) -> f32 {
245    if track.play_res_x == ParsedTrack::default().play_res_x
246        && track.play_res_y == ParsedTrack::default().play_res_y
247        && track.layout_res_x == 0
248        && track.layout_res_y == 0
249    {
250        return f32::INFINITY;
251    }
252    let margin_l = resolve_margin(event.margin_l, style.margin_l).max(0);
253    let margin_r = resolve_margin(event.margin_r, style.margin_r).max(0);
254    (track.play_res_x - margin_l - margin_r).max(0) as f32
255}
256
257fn wrap_layout_lines(
258    lines: Vec<LayoutLine>,
259    max_width: f32,
260    wrap_style: i32,
261    language: &str,
262) -> RassaResult<Vec<LayoutLine>> {
263    if wrap_style == 2 || max_width <= 0.0 || !max_width.is_finite() {
264        return Ok(lines);
265    }
266
267    let mut wrapped = Vec::new();
268    for line in lines {
269        wrapped.extend(wrap_layout_line(line, max_width, wrap_style, language)?);
270    }
271    Ok(wrapped)
272}
273
274#[derive(Clone, Debug)]
275struct LayoutPiece {
276    text: String,
277    run: LayoutGlyphRun,
278    width: f32,
279    char_index: usize,
280}
281
282fn wrap_layout_line(
283    line: LayoutLine,
284    max_width: f32,
285    wrap_style: i32,
286    language: &str,
287) -> RassaResult<Vec<LayoutLine>> {
288    if line.width <= max_width || line.text.chars().count() <= 1 {
289        return Ok(vec![line]);
290    }
291
292    let breaks = classify_line_breaks(&line.text, Some(language))?;
293    let pieces = line_to_pieces(&line);
294    if pieces.len() <= 1 {
295        return Ok(vec![line]);
296    }
297
298    let mut output = Vec::new();
299    let mut current: Vec<LayoutPiece> = Vec::new();
300    let mut current_width = 0.0_f32;
301    let mut last_break_pos: Option<usize> = None;
302
303    for piece in pieces.iter().cloned() {
304        current_width += piece.width;
305        current.push(piece);
306        let char_index = current.last().map(|piece| piece.char_index).unwrap_or(0);
307        if matches!(
308            breaks.get(char_index),
309            Some(LineBreakOpportunity::Allowed | LineBreakOpportunity::Mandatory)
310        ) {
311            last_break_pos = Some(current.len());
312        }
313
314        if current_width > max_width && current.len() > 1 {
315            let split_at = last_break_pos
316                .filter(|pos| *pos > 0 && *pos < current.len())
317                .unwrap_or(current.len() - 1);
318            let mut remainder = current.split_off(split_at);
319            trim_wrapped_line_edges(&mut current, false);
320            if !current.is_empty() {
321                output.push(line_from_pieces(&line, &current));
322            }
323            trim_wrapped_line_edges(&mut remainder, true);
324            current_width = pieces_width(&remainder);
325            current = remainder;
326            last_break_pos = last_allowed_break_pos(&current, &breaks);
327        }
328    }
329
330    trim_wrapped_line_edges(&mut current, false);
331    if !current.is_empty() {
332        output.push(line_from_pieces(&line, &current));
333    }
334
335    if wrap_style == 0 && output.len() == 2 {
336        if let Some(balanced) = balanced_two_line_wrap(&line, &pieces, &breaks, max_width) {
337            return Ok(balanced);
338        }
339    }
340
341    if output.is_empty() {
342        Ok(vec![line])
343    } else {
344        Ok(output)
345    }
346}
347
348fn balanced_two_line_wrap(
349    source: &LayoutLine,
350    pieces: &[LayoutPiece],
351    breaks: &[LineBreakOpportunity],
352    max_width: f32,
353) -> Option<Vec<LayoutLine>> {
354    let total = pieces_width(pieces);
355    let mut best: Option<(usize, f32)> = None;
356    for index in 1..pieces.len() {
357        let previous = &pieces[index - 1];
358        if !matches!(
359            breaks.get(previous.char_index),
360            Some(LineBreakOpportunity::Allowed | LineBreakOpportunity::Mandatory)
361        ) {
362            continue;
363        }
364        let left_width = pieces_width(&pieces[..index]);
365        let right_width = total - left_width;
366        if left_width <= 0.0
367            || right_width <= 0.0
368            || left_width > max_width
369            || right_width > max_width
370        {
371            continue;
372        }
373        let score = (left_width - right_width).abs();
374        if best.is_none_or(|(_, best_score)| score < best_score) {
375            best = Some((index, score));
376        }
377    }
378
379    let (split_at, _) = best?;
380    let mut first = pieces[..split_at].to_vec();
381    let mut second = pieces[split_at..].to_vec();
382    trim_wrapped_line_edges(&mut first, false);
383    trim_wrapped_line_edges(&mut second, true);
384    if first.is_empty() || second.is_empty() {
385        return None;
386    }
387    Some(vec![
388        line_from_pieces(source, &first),
389        line_from_pieces(source, &second),
390    ])
391}
392
393fn line_to_pieces(line: &LayoutLine) -> Vec<LayoutPiece> {
394    let mut pieces = Vec::new();
395    let mut char_index = 0_usize;
396    for run in &line.runs {
397        let chars = run.text.chars().collect::<Vec<_>>();
398        if run.drawing.is_some() || chars.is_empty() || chars.len() != run.glyphs.len() {
399            pieces.push(LayoutPiece {
400                text: run.text.clone(),
401                run: run.clone(),
402                width: run.width,
403                char_index: char_index + chars.len().saturating_sub(1),
404            });
405            char_index += chars.len();
406            continue;
407        }
408
409        let scale_x = run.style.scale_x.max(0.0) as f32;
410        let spacing = if run.style.spacing.is_finite() {
411            run.style.spacing as f32 * scale_x
412        } else {
413            0.0
414        };
415        for (offset, (character, glyph)) in chars.into_iter().zip(run.glyphs.iter()).enumerate() {
416            let mut piece_run = run.clone();
417            piece_run.text = character.to_string();
418            piece_run.glyphs = vec![glyph.clone()];
419            piece_run.width = glyph.x_advance * scale_x + spacing;
420            pieces.push(LayoutPiece {
421                text: character.to_string(),
422                width: piece_run.width,
423                run: piece_run,
424                char_index: char_index + offset,
425            });
426        }
427        char_index += run.text.chars().count();
428    }
429    pieces
430}
431
432fn trim_wrapped_line_edges(pieces: &mut Vec<LayoutPiece>, trim_leading: bool) {
433    while pieces
434        .last()
435        .is_some_and(|piece| piece.text.chars().all(char::is_whitespace))
436    {
437        pieces.pop();
438    }
439    if trim_leading {
440        let leading = pieces
441            .iter()
442            .take_while(|piece| piece.text.chars().all(char::is_whitespace))
443            .count();
444        if leading > 0 {
445            pieces.drain(0..leading);
446        }
447    }
448}
449
450fn pieces_width(pieces: &[LayoutPiece]) -> f32 {
451    pieces.iter().map(|piece| piece.width).sum()
452}
453
454fn last_allowed_break_pos(
455    pieces: &[LayoutPiece],
456    breaks: &[LineBreakOpportunity],
457) -> Option<usize> {
458    pieces.iter().enumerate().rev().find_map(|(index, piece)| {
459        matches!(
460            breaks.get(piece.char_index),
461            Some(LineBreakOpportunity::Allowed | LineBreakOpportunity::Mandatory)
462        )
463        .then_some(index + 1)
464    })
465}
466
467fn line_from_pieces(source: &LayoutLine, pieces: &[LayoutPiece]) -> LayoutLine {
468    let runs = pieces
469        .iter()
470        .map(|piece| piece.run.clone())
471        .collect::<Vec<_>>();
472    let text = pieces
473        .iter()
474        .map(|piece| piece.text.as_str())
475        .collect::<String>();
476    let glyph_count = runs.iter().map(|run| run.glyphs.len()).sum();
477    let width = runs.iter().map(|run| run.width).sum();
478    LayoutLine {
479        event_index: source.event_index,
480        style_index: source.style_index,
481        text,
482        direction: source.direction,
483        glyph_count,
484        width,
485        runs,
486    }
487}
488
489fn text_run_width(glyphs: &[GlyphInfo], style: &ParsedSpanStyle) -> f32 {
490    let scale_x = style.scale_x.max(0.0) as f32;
491    let spacing = if style.spacing.is_finite() {
492        style.spacing as f32 * scale_x
493    } else {
494        0.0
495    };
496    glyphs
497        .iter()
498        .map(|glyph| glyph.x_advance * scale_x + spacing)
499        .sum()
500}
501
502fn split_text_by_font<P: FontProvider>(
503    text: &str,
504    provider: &P,
505    family: &str,
506    style: Option<String>,
507) -> Vec<(String, FontMatch)> {
508    let base_font = provider.resolve(&FontQuery {
509        family: family.to_string(),
510        style: style.clone(),
511    });
512    let mut chunks: Vec<(String, FontMatch)> = Vec::new();
513
514    for character in text.chars() {
515        let font = if base_font.path.is_none()
516            || character.is_whitespace()
517            || character.is_control()
518            || base_font
519                .path
520                .as_ref()
521                .is_some_and(|_| font_match_supports_text(&base_font, &character.to_string()))
522        {
523            base_font.clone()
524        } else {
525            resolve_system_font_for_char(family, style.as_deref(), character)
526                .map(|(resolved_family, resolved_path, face_index)| FontMatch {
527                    family: resolved_family,
528                    path: resolved_path,
529                    face_index,
530                    style: style.clone(),
531                    provider: base_font.provider,
532                })
533                .unwrap_or_else(|| base_font.clone())
534        };
535
536        if let Some((chunk, chunk_font)) = chunks.last_mut() {
537            if same_font_match(chunk_font, &font) {
538                chunk.push(character);
539                continue;
540            }
541        }
542        chunks.push((character.to_string(), font));
543    }
544
545    chunks
546}
547
548fn same_font_match(left: &FontMatch, right: &FontMatch) -> bool {
549    left.family == right.family
550        && left.path == right.path
551        && left.face_index == right.face_index
552        && left.style == right.style
553}
554
555fn font_style_name(style: &ParsedSpanStyle) -> Option<String> {
556    match (style.bold, style.italic) {
557        (true, true) => Some("Bold Italic".to_string()),
558        (true, false) => Some("Bold".to_string()),
559        (false, true) => Some("Italic".to_string()),
560        (false, false) => None,
561    }
562}
563
564fn normalize_style_index(track: &ParsedTrack, event: &ParsedEvent) -> usize {
565    if track.styles.is_empty() {
566        return 0;
567    }
568
569    let candidate = usize::try_from(event.style).unwrap_or(0);
570    if candidate < track.styles.len() {
571        candidate
572    } else {
573        usize::try_from(track.default_style)
574            .ok()
575            .filter(|index| *index < track.styles.len())
576            .unwrap_or(0)
577    }
578}
579
580fn resolve_margin(event_margin: i32, style_margin: i32) -> i32 {
581    if event_margin == 0 {
582        style_margin
583    } else {
584        event_margin
585    }
586}
587
588fn normalize_justify(justify: i32, alignment: i32) -> i32 {
589    if justify != ass::ASS_JUSTIFY_AUTO {
590        return justify;
591    }
592
593    match alignment & 0x3 {
594        ass::HALIGN_LEFT => ass::ASS_JUSTIFY_LEFT,
595        ass::HALIGN_RIGHT => ass::ASS_JUSTIFY_RIGHT,
596        _ => ass::ASS_JUSTIFY_CENTER,
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use rassa_fonts::{FontconfigProvider, NullFontProvider, font_match_supports_text};
604    use rassa_parse::{ParsedKaraokeMode, ParsedTrack, parse_script_text};
605
606    fn parse_track(input: &str) -> ParsedTrack {
607        parse_script_text(input).expect("script should parse")
608    }
609
610    #[test]
611    fn layout_uses_style_font_and_event_margins() {
612        let track = parse_track(
613            "[Script Info]\nLanguage: en\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding, Justify\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,11,12,13,1,0\nStyle: Sign,DejaVu Sans,28,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,9,21,22,23,1,0\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Sign,,0030,0000,0040,,Visible text",
614        );
615        let engine = LayoutEngine::new();
616        let provider = NullFontProvider;
617        let layout = engine
618            .layout_track_event(&track, 0, &provider)
619            .expect("layout should succeed");
620
621        assert_eq!(layout.style_index, 1);
622        assert_eq!(layout.font_family, "DejaVu Sans");
623        assert_eq!(layout.margin_l, 30);
624        assert_eq!(layout.margin_r, 22);
625        assert_eq!(layout.margin_v, 40);
626        assert_eq!(layout.lines.len(), 1);
627        assert_eq!(layout.lines[0].glyph_count, "Visible text".chars().count());
628        assert_eq!(layout.lines[0].runs.len(), 1);
629    }
630
631    #[test]
632    fn override_italic_resolves_italic_font_style() {
633        let track = parse_track(
634            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,DejaVu Sans,40,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,5,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\i1}italic",
635        );
636        let engine = LayoutEngine::new();
637        let provider = FontconfigProvider::new();
638        let layout = engine
639            .layout_track_event(&track, 0, &provider)
640            .expect("layout should succeed");
641        let run = layout.lines[0].runs.first().expect("italic run");
642
643        assert!(run.style.italic);
644        assert!(
645            run.font
646                .style
647                .as_deref()
648                .unwrap_or_default()
649                .to_ascii_lowercase()
650                .contains("italic"),
651            "italic override must request an italic font face/style, got {:?}",
652            run.font.style
653        );
654    }
655
656    #[test]
657    fn layout_splits_lines_on_mandatory_breaks() {
658        let mut track = parse_track(
659            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,seed",
660        );
661        track.events[0].text = "a\nb".to_string();
662        let engine = LayoutEngine::new();
663        let provider = NullFontProvider;
664        let layout = engine
665            .layout_track_event(&track, 0, &provider)
666            .expect("layout should succeed");
667
668        assert_eq!(layout.lines.len(), 2);
669        assert_eq!(layout.lines[0].text, "a");
670        assert_eq!(layout.lines[1].text, "b");
671    }
672
673    #[test]
674    fn layout_wraps_long_text_at_unicode_line_breaks() {
675        let track = parse_track(
676            "[Script Info]
677PlayResX: 8
678WrapStyle: 0
679
680[V4+ Styles]
681Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
682Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,2,2,0,1
683
684[Events]
685Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
686Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,alpha beta gamma delta",
687        );
688        let engine = LayoutEngine::new();
689        let provider = NullFontProvider;
690        let layout = engine
691            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
692            .expect("layout should succeed");
693
694        assert!(layout.lines.len() > 1);
695        assert!(layout.lines.iter().all(|line| line.width <= 4.0));
696        assert!(layout.lines.iter().all(|line| !line.text.starts_with(' ')));
697        assert!(layout.lines.iter().all(|line| !line.text.ends_with(' ')));
698    }
699
700    #[test]
701    fn layout_q2_disables_automatic_wrapping() {
702        let track = parse_track(
703            "[Script Info]
704PlayResX: 8
705WrapStyle: 0
706
707[V4+ Styles]
708Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
709Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,2,2,0,1
710
711[Events]
712Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
713Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\q2}alpha beta gamma delta",
714        );
715        let engine = LayoutEngine::new();
716        let provider = NullFontProvider;
717        let layout = engine
718            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
719            .expect("layout should succeed");
720
721        assert_eq!(layout.lines.len(), 1);
722        assert!(layout.lines[0].width > 4.0);
723    }
724
725    #[test]
726    fn layout_wraps_positioned_center_text_against_margins_not_anchor_space() {
727        let track = parse_track(
728            "[Script Info]
729PlayResX: 40
730WrapStyle: 0
731
732[V4+ Styles]
733Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
734Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,5,2,2,0,1
735
736[Events]
737Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
738Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\pos(10,20)\\an5\\q0}alpha beta gamma delta",
739        );
740        let engine = LayoutEngine::new();
741        let provider = NullFontProvider;
742        let layout = engine
743            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
744            .expect("layout should succeed");
745
746        assert_eq!(layout.lines.len(), 1);
747        assert_eq!(layout.lines[0].text, "alpha beta gamma delta");
748    }
749
750    #[test]
751    fn layout_wraps_cjk_using_unicode_line_break_opportunities() {
752        let track = parse_track(
753            "[Script Info]
754Language: ja
755PlayResX: 6
756WrapStyle: 0
757
758[V4+ Styles]
759Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
760Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,2,2,0,1
761
762[Events]
763Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
764Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,日本語日本語",
765        );
766        let engine = LayoutEngine::new();
767        let provider = NullFontProvider;
768        let layout = engine
769            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
770            .expect("layout should succeed");
771
772        assert!(layout.lines.len() > 1);
773        assert!(layout.lines.iter().all(|line| line.width <= 2.0));
774    }
775
776    #[test]
777    fn layout_applies_font_override_runs() {
778        let track = parse_track(
779            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\fnDejaVu Sans}Hello{\\fnArial} world",
780        );
781        let engine = LayoutEngine::new();
782        let provider = NullFontProvider;
783        let layout = engine
784            .layout_track_event(&track, 0, &provider)
785            .expect("layout should succeed");
786
787        assert_eq!(layout.lines.len(), 1);
788        assert_eq!(layout.lines[0].runs.len(), 2);
789        assert_eq!(layout.lines[0].runs[0].style.font_name, "DejaVu Sans");
790        assert_eq!(layout.lines[0].runs[1].style.font_name, "Arial");
791    }
792
793    #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
794    #[test]
795    fn layout_splits_cjk_text_to_covered_fallback_font_run() {
796        if resolve_system_font_for_char("DejaVu Sans", None, '日').is_none() {
797            eprintln!("skipping: system fontconfig has no CJK-capable fallback font");
798            return;
799        }
800        let track = parse_track(
801            "[Script Info]\nLanguage: ja\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,DejaVu Sans,32,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,abc 日本語",
802        );
803        let engine = LayoutEngine::new();
804        let provider = FontconfigProvider::new();
805        let layout = engine
806            .layout_track_event(&track, 0, &provider)
807            .expect("layout should succeed");
808
809        let cjk_run = layout.lines[0]
810            .runs
811            .iter()
812            .find(|run| run.text.contains('日'))
813            .expect("CJK text should be retained in a glyph run");
814        assert!(font_match_supports_text(&cjk_run.font, "日本語"));
815        assert_ne!(cjk_run.font_family, "DejaVu Sans");
816    }
817
818    #[test]
819    fn layout_carries_clip_metadata() {
820        let track = parse_track(
821            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\iclip(10,20,30,40)}Clip",
822        );
823        let engine = LayoutEngine::new();
824        let provider = NullFontProvider;
825        let layout = engine
826            .layout_track_event(&track, 0, &provider)
827            .expect("layout should succeed");
828
829        assert_eq!(
830            layout.clip_rect,
831            Some(Rect {
832                x_min: 10,
833                y_min: 20,
834                x_max: 30,
835                y_max: 40
836            })
837        );
838        assert!(layout.vector_clip.is_none());
839        assert!(layout.inverse_clip);
840    }
841
842    #[test]
843    fn layout_carries_vector_clip_metadata() {
844        let track = parse_track(
845            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\clip(m 0 0 l 8 0 8 8 0 8)}Clip",
846        );
847        let engine = LayoutEngine::new();
848        let provider = NullFontProvider;
849        let layout = engine
850            .layout_track_event(&track, 0, &provider)
851            .expect("layout should succeed");
852
853        assert!(layout.clip_rect.is_none());
854        assert!(layout.vector_clip.is_some());
855        assert!(!layout.inverse_clip);
856    }
857
858    #[test]
859    fn layout_carries_move_metadata() {
860        let track = parse_track(
861            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\move(1,2,3,4,50,150)}Move",
862        );
863        let engine = LayoutEngine::new();
864        let provider = NullFontProvider;
865        let layout = engine
866            .layout_track_event(&track, 0, &provider)
867            .expect("layout should succeed");
868
869        assert_eq!(
870            layout.movement,
871            Some(ParsedMovement {
872                start: (1, 2),
873                end: (3, 4),
874                t1_ms: 50,
875                t2_ms: 150,
876            })
877        );
878    }
879
880    #[test]
881    fn layout_carries_fade_metadata() {
882        let track = parse_track(
883            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\fad(100,200)}Fade",
884        );
885        let engine = LayoutEngine::new();
886        let provider = NullFontProvider;
887        let layout = engine
888            .layout_track_event(&track, 0, &provider)
889            .expect("layout should succeed");
890
891        assert_eq!(
892            layout.fade,
893            Some(ParsedFade::Simple {
894                fade_in_ms: 100,
895                fade_out_ms: 200,
896            })
897        );
898    }
899
900    #[test]
901    fn layout_carries_full_fade_metadata() {
902        let track = parse_track(
903            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\fade(10,20,30,40,50,60,70)}Fade",
904        );
905        let engine = LayoutEngine::new();
906        let provider = NullFontProvider;
907        let layout = engine
908            .layout_track_event(&track, 0, &provider)
909            .expect("layout should succeed");
910
911        assert_eq!(
912            layout.fade,
913            Some(ParsedFade::Complex {
914                alpha1: 10,
915                alpha2: 20,
916                alpha3: 30,
917                t1_ms: 40,
918                t2_ms: 50,
919                t3_ms: 60,
920                t4_ms: 70,
921            })
922        );
923    }
924
925    #[test]
926    fn layout_carries_karaoke_metadata() {
927        let track = parse_track(
928            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\k10}Ka{\\k20}ra",
929        );
930        let engine = LayoutEngine::new();
931        let provider = NullFontProvider;
932        let layout = engine
933            .layout_track_event(&track, 0, &provider)
934            .expect("layout should succeed");
935
936        assert_eq!(layout.lines[0].runs.len(), 2);
937        assert_eq!(
938            layout.lines[0].runs[0].karaoke,
939            Some(ParsedKaraokeSpan {
940                start_ms: 0,
941                duration_ms: 100,
942                mode: ParsedKaraokeMode::FillSwap,
943            })
944        );
945        assert_eq!(
946            layout.lines[0].runs[1].karaoke,
947            Some(ParsedKaraokeSpan {
948                start_ms: 100,
949                duration_ms: 200,
950                mode: ParsedKaraokeMode::FillSwap,
951            })
952        );
953    }
954
955    #[test]
956    fn layout_carries_transform_metadata() {
957        let track = parse_track(
958            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H000000FF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\t(0,1000,\\bord4\\1c&H00112233&)}Hi",
959        );
960        let engine = LayoutEngine::new();
961        let provider = NullFontProvider;
962        let layout = engine
963            .layout_track_event(&track, 0, &provider)
964            .expect("layout should succeed");
965
966        assert_eq!(layout.lines[0].runs[0].transforms.len(), 1);
967        assert_eq!(
968            layout.lines[0].runs[0].transforms[0].style.border,
969            Some(4.0)
970        );
971        assert_eq!(
972            layout.lines[0].runs[0].transforms[0].style.primary_colour,
973            Some(0x0011_2233)
974        );
975    }
976
977    #[test]
978    fn layout_carries_drawing_runs() {
979        let track = parse_track(
980            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\p1}m 0 0 l 8 0 8 8 0 8",
981        );
982        let engine = LayoutEngine::new();
983        let provider = NullFontProvider;
984        let layout = engine
985            .layout_track_event(&track, 0, &provider)
986            .expect("layout should succeed");
987
988        assert_eq!(layout.lines[0].runs.len(), 1);
989        assert!(layout.lines[0].runs[0].drawing.is_some());
990        assert_eq!(layout.lines[0].runs[0].width, 9.0);
991    }
992
993    #[test]
994    fn layout_carries_missing_override_metadata() {
995        let track = parse_track(
996            "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\u1\\s1\\a10\\q2\\org(320,240)\\frx12\\fry-8\\fax0.25\\fay-0.5\\xbord3\\ybord4\\xshad5\\yshad-6\\be2\\pbo7}Meta",
997        );
998        let engine = LayoutEngine::new();
999        let provider = NullFontProvider;
1000        let layout = engine
1001            .layout_track_event(&track, 0, &provider)
1002            .expect("layout should succeed");
1003
1004        assert_eq!(layout.alignment, ass::VALIGN_CENTER | ass::HALIGN_CENTER);
1005        assert_eq!(layout.wrap_style, Some(2));
1006        assert_eq!(layout.origin, Some((320, 240)));
1007        let style = &layout.lines[0].runs[0].style;
1008        assert!(style.underline);
1009        assert!(style.strike_out);
1010        assert_eq!(style.rotation_x, 12.0);
1011        assert_eq!(style.rotation_y, -8.0);
1012        assert_eq!(style.shear_x, 0.25);
1013        assert_eq!(style.shear_y, -0.5);
1014        assert_eq!(style.border_x, 3.0);
1015        assert_eq!(style.border_y, 4.0);
1016        assert_eq!(style.shadow_x, 5.0);
1017        assert_eq!(style.shadow_y, -6.0);
1018        assert_eq!(style.be, 2.0);
1019        assert_eq!(style.pbo, 7.0);
1020    }
1021
1022    #[test]
1023    fn layout_accepts_explicit_shaping_mode() {
1024        let track = parse_track(
1025            "[Script Info]\nLanguage: en\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,36,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,office",
1026        );
1027        let engine = LayoutEngine::new();
1028        let provider = FontconfigProvider::new();
1029        let simple = engine
1030            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
1031            .expect("simple layout should succeed");
1032        let complex = engine
1033            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Complex)
1034            .expect("complex layout should succeed");
1035
1036        assert_eq!(simple.lines.len(), 1);
1037        assert_eq!(complex.lines.len(), 1);
1038        assert_eq!(simple.lines[0].text, "office");
1039        assert_eq!(complex.lines[0].text, "office");
1040    }
1041}