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