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, ¤t));
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(¤t, &breaks);
333 }
334 }
335
336 trim_wrapped_line_edges(&mut current, false);
337 if !current.is_empty() {
338 output.push(line_from_pieces(&line, ¤t));
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}