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, ¤t));
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(¤t, &breaks);
339 }
340 }
341
342 trim_wrapped_line_edges(&mut current, false);
343 if !current.is_empty() {
344 output.push(line_from_pieces(&line, ¤t));
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}