Skip to main content

rassa_layout/
lib.rs

1use rassa_core::{RassaError, RassaResult, Rect, ass};
2use rassa_fonts::{FontMatch, FontProvider, FontQuery};
3use rassa_parse::{
4    ParsedDrawing, ParsedEvent, ParsedFade, ParsedKaraokeSpan, ParsedMovement, ParsedSpanStyle,
5    ParsedSpanTransform, ParsedTrack, ParsedVectorClip, parse_dialogue_text,
6};
7use rassa_shape::{GlyphInfo, ShapeEngine, ShapeRequest, ShapingMode};
8use rassa_unicode::BidiDirection;
9
10#[derive(Clone, Debug, Default, PartialEq)]
11pub struct LayoutGlyphRun {
12    pub text: String,
13    pub direction: BidiDirection,
14    pub font_family: String,
15    pub font: FontMatch,
16    pub glyphs: Vec<GlyphInfo>,
17    pub width: f32,
18    pub style: ParsedSpanStyle,
19    pub transforms: Vec<ParsedSpanTransform>,
20    pub karaoke: Option<ParsedKaraokeSpan>,
21    pub drawing: Option<ParsedDrawing>,
22}
23
24#[derive(Clone, Debug, Default, PartialEq)]
25pub struct LayoutLine {
26    pub event_index: usize,
27    pub style_index: usize,
28    pub text: String,
29    pub direction: BidiDirection,
30    pub glyph_count: usize,
31    pub width: f32,
32    pub runs: Vec<LayoutGlyphRun>,
33}
34
35#[derive(Clone, Debug, Default, PartialEq)]
36pub struct LayoutEvent {
37    pub event_index: usize,
38    pub style_index: usize,
39    pub text: String,
40    pub font_family: String,
41    pub font: FontMatch,
42    pub alignment: i32,
43    pub justify: i32,
44    pub margin_l: i32,
45    pub margin_r: i32,
46    pub margin_v: i32,
47    pub position: Option<(i32, i32)>,
48    pub movement: Option<ParsedMovement>,
49    pub fade: Option<ParsedFade>,
50    pub clip_rect: Option<Rect>,
51    pub vector_clip: Option<ParsedVectorClip>,
52    pub inverse_clip: bool,
53    pub wrap_style: Option<i32>,
54    pub origin: Option<(i32, i32)>,
55    pub lines: Vec<LayoutLine>,
56}
57
58#[derive(Default)]
59pub struct LayoutEngine {
60    shaper: ShapeEngine,
61}
62
63impl LayoutEngine {
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    pub fn layout_track_event_with_mode<P: FontProvider>(
69        &self,
70        track: &ParsedTrack,
71        event_index: usize,
72        provider: &P,
73        shaping_mode: ShapingMode,
74    ) -> RassaResult<LayoutEvent> {
75        let event = track
76            .events
77            .get(event_index)
78            .ok_or_else(|| RassaError::new(format!("event index {event_index} out of range")))?;
79        let style_index = normalize_style_index(track, event);
80        let style = track
81            .styles
82            .get(style_index)
83            .unwrap_or(&track.styles[track.default_style as usize]);
84        let parsed_text = parse_dialogue_text(&event.text, style, &track.styles);
85        let font = provider.resolve(&FontQuery {
86            family: style.font_name.clone(),
87            style: None,
88        });
89        let lines = parsed_text
90            .lines
91            .iter()
92            .map(|line| {
93                layout_line_from_text(
94                    event_index,
95                    style_index,
96                    line,
97                    provider,
98                    &self.shaper,
99                    &track.language,
100                    shaping_mode,
101                )
102            })
103            .collect::<RassaResult<Vec<_>>>()?;
104
105        Ok(LayoutEvent {
106            event_index,
107            style_index,
108            text: parsed_text
109                .lines
110                .iter()
111                .map(|line| line.text.as_str())
112                .collect::<Vec<_>>()
113                .join("\n"),
114            font_family: font.family.clone(),
115            font: font.clone(),
116            alignment: parsed_text.alignment.unwrap_or(style.alignment),
117            justify: normalize_justify(style.justify, style.alignment),
118            margin_l: resolve_margin(event.margin_l, style.margin_l),
119            margin_r: resolve_margin(event.margin_r, style.margin_r),
120            margin_v: resolve_margin(event.margin_v, style.margin_v),
121            position: parsed_text.position,
122            movement: parsed_text.movement,
123            fade: parsed_text.fade,
124            clip_rect: parsed_text.clip_rect,
125            vector_clip: parsed_text.vector_clip,
126            inverse_clip: parsed_text.inverse_clip,
127            wrap_style: parsed_text.wrap_style,
128            origin: parsed_text.origin,
129            lines,
130        })
131    }
132
133    pub fn layout_track_event<P: FontProvider>(
134        &self,
135        track: &ParsedTrack,
136        event_index: usize,
137        provider: &P,
138    ) -> RassaResult<LayoutEvent> {
139        self.layout_track_event_with_mode(track, event_index, provider, ShapingMode::Complex)
140    }
141}
142
143fn layout_line_from_text<P: FontProvider>(
144    event_index: usize,
145    style_index: usize,
146    line: &rassa_parse::ParsedTextLine,
147    provider: &P,
148    shaper: &ShapeEngine,
149    language: &str,
150    shaping_mode: ShapingMode,
151) -> RassaResult<LayoutLine> {
152    let mut runs = Vec::new();
153    let mut line_direction = BidiDirection::LeftToRight;
154    for span in &line.spans {
155        if span.text.is_empty() {
156            continue;
157        }
158        let font = provider.resolve(&FontQuery {
159            family: span.style.font_name.clone(),
160            style: font_style_name(&span.style),
161        });
162        if let Some(drawing) = &span.drawing {
163            let width = drawing
164                .bounds()
165                .map(|bounds| bounds.width() as f32 * span.style.scale_x.max(0.0) as f32)
166                .unwrap_or_default();
167            runs.push(LayoutGlyphRun {
168                text: span.text.clone(),
169                direction: line_direction,
170                font_family: font.family.clone(),
171                font: font.clone(),
172                glyphs: Vec::new(),
173                width,
174                style: span.style.clone(),
175                transforms: span.transforms.clone(),
176                karaoke: span.karaoke,
177                drawing: Some(drawing.clone()),
178            });
179            continue;
180        }
181        let shaped = shaper.shape_text(
182            provider,
183            &ShapeRequest::new(&span.text, &span.style.font_name)
184                .with_style(font_style_name(&span.style).unwrap_or_default())
185                .with_language(language)
186                .with_font_size(span.style.font_size as f32)
187                .with_mode(shaping_mode),
188        )?;
189        for shaped_run in shaped.runs {
190            line_direction = shaped_run.direction;
191            runs.push(LayoutGlyphRun {
192                text: shaped_run.text,
193                direction: shaped_run.direction,
194                font_family: font.family.clone(),
195                font: font.clone(),
196                width: text_run_width(&shaped_run.glyphs, &span.style),
197                glyphs: shaped_run.glyphs,
198                style: span.style.clone(),
199                transforms: span.transforms.clone(),
200                karaoke: span.karaoke,
201                drawing: None,
202            });
203        }
204    }
205
206    let glyph_count = runs.iter().map(|run| run.glyphs.len()).sum();
207    let width = runs.iter().map(|run| run.width).sum();
208    Ok(LayoutLine {
209        event_index,
210        style_index,
211        text: line.text.clone(),
212        direction: line_direction,
213        glyph_count,
214        width,
215        runs,
216    })
217}
218
219fn text_run_width(glyphs: &[GlyphInfo], style: &ParsedSpanStyle) -> f32 {
220    let scale_x = style.scale_x.max(0.0) as f32;
221    let spacing = if style.spacing.is_finite() {
222        style.spacing as f32 * scale_x
223    } else {
224        0.0
225    };
226    glyphs
227        .iter()
228        .map(|glyph| glyph.x_advance * scale_x + spacing)
229        .sum()
230}
231
232fn font_style_name(style: &ParsedSpanStyle) -> Option<String> {
233    match (style.bold, style.italic) {
234        (true, true) => Some("Bold Italic".to_string()),
235        (true, false) => Some("Bold".to_string()),
236        (false, true) => Some("Italic".to_string()),
237        (false, false) => None,
238    }
239}
240
241fn normalize_style_index(track: &ParsedTrack, event: &ParsedEvent) -> usize {
242    if track.styles.is_empty() {
243        return 0;
244    }
245
246    let candidate = usize::try_from(event.style).unwrap_or(0);
247    if candidate < track.styles.len() {
248        candidate
249    } else {
250        usize::try_from(track.default_style)
251            .ok()
252            .filter(|index| *index < track.styles.len())
253            .unwrap_or(0)
254    }
255}
256
257fn resolve_margin(event_margin: i32, style_margin: i32) -> i32 {
258    if event_margin == 0 {
259        style_margin
260    } else {
261        event_margin
262    }
263}
264
265fn normalize_justify(justify: i32, alignment: i32) -> i32 {
266    if justify != ass::ASS_JUSTIFY_AUTO {
267        return justify;
268    }
269
270    match alignment & 0x3 {
271        ass::HALIGN_LEFT => ass::ASS_JUSTIFY_LEFT,
272        ass::HALIGN_RIGHT => ass::ASS_JUSTIFY_RIGHT,
273        _ => ass::ASS_JUSTIFY_CENTER,
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use rassa_fonts::{FontconfigProvider, NullFontProvider};
281    use rassa_parse::{ParsedKaraokeMode, ParsedTrack, parse_script_text};
282
283    fn parse_track(input: &str) -> ParsedTrack {
284        parse_script_text(input).expect("script should parse")
285    }
286
287    #[test]
288    fn layout_uses_style_font_and_event_margins() {
289        let track = parse_track(
290            "[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",
291        );
292        let engine = LayoutEngine::new();
293        let provider = NullFontProvider;
294        let layout = engine
295            .layout_track_event(&track, 0, &provider)
296            .expect("layout should succeed");
297
298        assert_eq!(layout.style_index, 1);
299        assert_eq!(layout.font_family, "DejaVu Sans");
300        assert_eq!(layout.margin_l, 30);
301        assert_eq!(layout.margin_r, 22);
302        assert_eq!(layout.margin_v, 40);
303        assert_eq!(layout.lines.len(), 1);
304        assert_eq!(layout.lines[0].glyph_count, "Visible text".chars().count());
305        assert_eq!(layout.lines[0].runs.len(), 1);
306    }
307
308    #[test]
309    fn layout_splits_lines_on_mandatory_breaks() {
310        let mut track = parse_track(
311            "[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",
312        );
313        track.events[0].text = "a\nb".to_string();
314        let engine = LayoutEngine::new();
315        let provider = NullFontProvider;
316        let layout = engine
317            .layout_track_event(&track, 0, &provider)
318            .expect("layout should succeed");
319
320        assert_eq!(layout.lines.len(), 2);
321        assert_eq!(layout.lines[0].text, "a");
322        assert_eq!(layout.lines[1].text, "b");
323    }
324
325    #[test]
326    fn layout_applies_font_override_runs() {
327        let track = parse_track(
328            "[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",
329        );
330        let engine = LayoutEngine::new();
331        let provider = NullFontProvider;
332        let layout = engine
333            .layout_track_event(&track, 0, &provider)
334            .expect("layout should succeed");
335
336        assert_eq!(layout.lines.len(), 1);
337        assert_eq!(layout.lines[0].runs.len(), 2);
338        assert_eq!(layout.lines[0].runs[0].style.font_name, "DejaVu Sans");
339        assert_eq!(layout.lines[0].runs[1].style.font_name, "Arial");
340    }
341
342    #[test]
343    fn layout_carries_clip_metadata() {
344        let track = parse_track(
345            "[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",
346        );
347        let engine = LayoutEngine::new();
348        let provider = NullFontProvider;
349        let layout = engine
350            .layout_track_event(&track, 0, &provider)
351            .expect("layout should succeed");
352
353        assert_eq!(
354            layout.clip_rect,
355            Some(Rect {
356                x_min: 10,
357                y_min: 20,
358                x_max: 30,
359                y_max: 40
360            })
361        );
362        assert!(layout.vector_clip.is_none());
363        assert!(layout.inverse_clip);
364    }
365
366    #[test]
367    fn layout_carries_vector_clip_metadata() {
368        let track = parse_track(
369            "[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",
370        );
371        let engine = LayoutEngine::new();
372        let provider = NullFontProvider;
373        let layout = engine
374            .layout_track_event(&track, 0, &provider)
375            .expect("layout should succeed");
376
377        assert!(layout.clip_rect.is_none());
378        assert!(layout.vector_clip.is_some());
379        assert!(!layout.inverse_clip);
380    }
381
382    #[test]
383    fn layout_carries_move_metadata() {
384        let track = parse_track(
385            "[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",
386        );
387        let engine = LayoutEngine::new();
388        let provider = NullFontProvider;
389        let layout = engine
390            .layout_track_event(&track, 0, &provider)
391            .expect("layout should succeed");
392
393        assert_eq!(
394            layout.movement,
395            Some(ParsedMovement {
396                start: (1, 2),
397                end: (3, 4),
398                t1_ms: 50,
399                t2_ms: 150,
400            })
401        );
402    }
403
404    #[test]
405    fn layout_carries_fade_metadata() {
406        let track = parse_track(
407            "[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",
408        );
409        let engine = LayoutEngine::new();
410        let provider = NullFontProvider;
411        let layout = engine
412            .layout_track_event(&track, 0, &provider)
413            .expect("layout should succeed");
414
415        assert_eq!(
416            layout.fade,
417            Some(ParsedFade::Simple {
418                fade_in_ms: 100,
419                fade_out_ms: 200,
420            })
421        );
422    }
423
424    #[test]
425    fn layout_carries_full_fade_metadata() {
426        let track = parse_track(
427            "[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",
428        );
429        let engine = LayoutEngine::new();
430        let provider = NullFontProvider;
431        let layout = engine
432            .layout_track_event(&track, 0, &provider)
433            .expect("layout should succeed");
434
435        assert_eq!(
436            layout.fade,
437            Some(ParsedFade::Complex {
438                alpha1: 10,
439                alpha2: 20,
440                alpha3: 30,
441                t1_ms: 40,
442                t2_ms: 50,
443                t3_ms: 60,
444                t4_ms: 70,
445            })
446        );
447    }
448
449    #[test]
450    fn layout_carries_karaoke_metadata() {
451        let track = parse_track(
452            "[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",
453        );
454        let engine = LayoutEngine::new();
455        let provider = NullFontProvider;
456        let layout = engine
457            .layout_track_event(&track, 0, &provider)
458            .expect("layout should succeed");
459
460        assert_eq!(layout.lines[0].runs.len(), 2);
461        assert_eq!(
462            layout.lines[0].runs[0].karaoke,
463            Some(ParsedKaraokeSpan {
464                start_ms: 0,
465                duration_ms: 100,
466                mode: ParsedKaraokeMode::FillSwap,
467            })
468        );
469        assert_eq!(
470            layout.lines[0].runs[1].karaoke,
471            Some(ParsedKaraokeSpan {
472                start_ms: 100,
473                duration_ms: 200,
474                mode: ParsedKaraokeMode::FillSwap,
475            })
476        );
477    }
478
479    #[test]
480    fn layout_carries_transform_metadata() {
481        let track = parse_track(
482            "[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",
483        );
484        let engine = LayoutEngine::new();
485        let provider = NullFontProvider;
486        let layout = engine
487            .layout_track_event(&track, 0, &provider)
488            .expect("layout should succeed");
489
490        assert_eq!(layout.lines[0].runs[0].transforms.len(), 1);
491        assert_eq!(
492            layout.lines[0].runs[0].transforms[0].style.border,
493            Some(4.0)
494        );
495        assert_eq!(
496            layout.lines[0].runs[0].transforms[0].style.primary_colour,
497            Some(0x0011_2233)
498        );
499    }
500
501    #[test]
502    fn layout_carries_drawing_runs() {
503        let track = parse_track(
504            "[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",
505        );
506        let engine = LayoutEngine::new();
507        let provider = NullFontProvider;
508        let layout = engine
509            .layout_track_event(&track, 0, &provider)
510            .expect("layout should succeed");
511
512        assert_eq!(layout.lines[0].runs.len(), 1);
513        assert!(layout.lines[0].runs[0].drawing.is_some());
514        assert_eq!(layout.lines[0].runs[0].width, 9.0);
515    }
516
517    #[test]
518    fn layout_carries_missing_override_metadata() {
519        let track = parse_track(
520            "[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",
521        );
522        let engine = LayoutEngine::new();
523        let provider = NullFontProvider;
524        let layout = engine
525            .layout_track_event(&track, 0, &provider)
526            .expect("layout should succeed");
527
528        assert_eq!(layout.alignment, ass::VALIGN_CENTER | ass::HALIGN_CENTER);
529        assert_eq!(layout.wrap_style, Some(2));
530        assert_eq!(layout.origin, Some((320, 240)));
531        let style = &layout.lines[0].runs[0].style;
532        assert!(style.underline);
533        assert!(style.strike_out);
534        assert_eq!(style.rotation_x, 12.0);
535        assert_eq!(style.rotation_y, -8.0);
536        assert_eq!(style.shear_x, 0.25);
537        assert_eq!(style.shear_y, -0.5);
538        assert_eq!(style.border_x, 3.0);
539        assert_eq!(style.border_y, 4.0);
540        assert_eq!(style.shadow_x, 5.0);
541        assert_eq!(style.shadow_y, -6.0);
542        assert_eq!(style.be, 2.0);
543        assert_eq!(style.pbo, 7.0);
544    }
545
546    #[test]
547    fn layout_accepts_explicit_shaping_mode() {
548        let track = parse_track(
549            "[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",
550        );
551        let engine = LayoutEngine::new();
552        let provider = FontconfigProvider::new();
553        let simple = engine
554            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
555            .expect("simple layout should succeed");
556        let complex = engine
557            .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Complex)
558            .expect("complex layout should succeed");
559
560        assert_eq!(simple.lines.len(), 1);
561        assert_eq!(complex.lines.len(), 1);
562        assert_eq!(simple.lines[0].text, "office");
563        assert_eq!(complex.lines[0].text, "office");
564    }
565}