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