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 total = pieces_width(pieces);
367 let mut best: Option<(usize, f32)> = None;
368 for index in 1..pieces.len() {
369 let previous = &pieces[index - 1];
370 if !matches!(
371 breaks.get(previous.char_index),
372 Some(LineBreakOpportunity::Allowed | LineBreakOpportunity::Mandatory)
373 ) {
374 continue;
375 }
376 let left_width = pieces_width(&pieces[..index]);
377 let right_width = total - left_width;
378 if left_width <= 0.0
379 || right_width <= 0.0
380 || left_width > max_width
381 || right_width > max_width
382 {
383 continue;
384 }
385 let score = (left_width - right_width).abs();
386 if best.is_none_or(|(_, best_score)| score < best_score) {
387 best = Some((index, score));
388 }
389 }
390
391 let (split_at, _) = best?;
392 let mut first = pieces[..split_at].to_vec();
393 let mut second = pieces[split_at..].to_vec();
394 trim_wrapped_line_edges(&mut first, false);
395 trim_wrapped_line_edges(&mut second, true);
396 if first.is_empty() || second.is_empty() {
397 return None;
398 }
399 Some(vec![
400 line_from_pieces(source, &first),
401 line_from_pieces(source, &second),
402 ])
403}
404
405fn line_to_pieces(line: &LayoutLine) -> Vec<LayoutPiece> {
406 let mut pieces = Vec::new();
407 let mut char_index = 0_usize;
408 for run in &line.runs {
409 let chars = run.text.chars().collect::<Vec<_>>();
410 if run.drawing.is_some() || chars.is_empty() || chars.len() != run.glyphs.len() {
411 pieces.push(LayoutPiece {
412 text: run.text.clone(),
413 run: run.clone(),
414 width: run.width,
415 char_index: char_index + chars.len().saturating_sub(1),
416 });
417 char_index += chars.len();
418 continue;
419 }
420
421 let scale_x = run.style.scale_x.max(0.0) as f32;
422 let spacing = if run.style.spacing.is_finite() {
423 run.style.spacing as f32 * scale_x
424 } else {
425 0.0
426 };
427 for (offset, (character, glyph)) in chars.into_iter().zip(run.glyphs.iter()).enumerate() {
428 let mut piece_run = run.clone();
429 piece_run.text = character.to_string();
430 piece_run.glyphs = vec![glyph.clone()];
431 piece_run.width = glyph.x_advance * scale_x + spacing;
432 pieces.push(LayoutPiece {
433 text: character.to_string(),
434 width: piece_run.width,
435 run: piece_run,
436 char_index: char_index + offset,
437 });
438 }
439 char_index += run.text.chars().count();
440 }
441 pieces
442}
443
444fn trim_wrapped_line_edges(pieces: &mut Vec<LayoutPiece>, trim_leading: bool) {
445 while pieces
446 .last()
447 .is_some_and(|piece| piece.text.chars().all(char::is_whitespace))
448 {
449 pieces.pop();
450 }
451 if trim_leading {
452 let leading = pieces
453 .iter()
454 .take_while(|piece| piece.text.chars().all(char::is_whitespace))
455 .count();
456 if leading > 0 {
457 pieces.drain(0..leading);
458 }
459 }
460}
461
462fn pieces_width(pieces: &[LayoutPiece]) -> f32 {
463 pieces.iter().map(|piece| piece.width).sum()
464}
465
466fn last_allowed_break_pos(
467 pieces: &[LayoutPiece],
468 breaks: &[LineBreakOpportunity],
469) -> Option<usize> {
470 pieces.iter().enumerate().rev().find_map(|(index, piece)| {
471 matches!(
472 breaks.get(piece.char_index),
473 Some(LineBreakOpportunity::Allowed | LineBreakOpportunity::Mandatory)
474 )
475 .then_some(index + 1)
476 })
477}
478
479fn line_from_pieces(source: &LayoutLine, pieces: &[LayoutPiece]) -> LayoutLine {
480 let runs = pieces
481 .iter()
482 .map(|piece| piece.run.clone())
483 .collect::<Vec<_>>();
484 let text = pieces
485 .iter()
486 .map(|piece| piece.text.as_str())
487 .collect::<String>();
488 let glyph_count = runs.iter().map(|run| run.glyphs.len()).sum();
489 let width = runs.iter().map(|run| run.width).sum();
490 LayoutLine {
491 event_index: source.event_index,
492 style_index: source.style_index,
493 text,
494 direction: source.direction,
495 glyph_count,
496 width,
497 runs,
498 }
499}
500
501fn text_run_width(glyphs: &[GlyphInfo], style: &ParsedSpanStyle) -> f32 {
502 let scale_x = style.scale_x.max(0.0) as f32;
503 let spacing = if style.spacing.is_finite() {
504 style.spacing as f32 * scale_x
505 } else {
506 0.0
507 };
508 glyphs
509 .iter()
510 .map(|glyph| glyph.x_advance * scale_x + spacing)
511 .sum()
512}
513
514fn split_text_by_font<P: FontProvider>(
515 text: &str,
516 provider: &P,
517 family: &str,
518 style: Option<String>,
519 weight: i32,
520) -> Vec<(String, FontMatch)> {
521 let base_font = provider.resolve(&FontQuery {
522 family: family.to_string(),
523 style: style.clone(),
524 weight: font_query_weight(weight),
525 });
526 let mut chunks: Vec<(String, FontMatch)> = Vec::new();
527
528 for character in text.chars() {
529 let font = if base_font.path.is_none()
530 || character.is_whitespace()
531 || character.is_control()
532 || base_font
533 .path
534 .as_ref()
535 .is_some_and(|_| font_match_supports_text(&base_font, &character.to_string()))
536 {
537 base_font.clone()
538 } else {
539 resolve_system_font_for_char(family, style.as_deref(), character)
540 .map(|(resolved_family, resolved_path, face_index)| FontMatch {
541 family: resolved_family,
542 path: resolved_path,
543 face_index,
544 style: style.clone(),
545 synthetic_bold: base_font.synthetic_bold,
546 synthetic_italic: base_font.synthetic_italic,
547 provider: base_font.provider,
548 })
549 .unwrap_or_else(|| base_font.clone())
550 };
551
552 if let Some((chunk, chunk_font)) = chunks.last_mut() {
553 if same_font_match(chunk_font, &font) {
554 chunk.push(character);
555 continue;
556 }
557 }
558 chunks.push((character.to_string(), font));
559 }
560
561 chunks
562}
563
564fn same_font_match(left: &FontMatch, right: &FontMatch) -> bool {
565 left.family == right.family
566 && left.path == right.path
567 && left.face_index == right.face_index
568 && left.style == right.style
569 && left.synthetic_bold == right.synthetic_bold
570 && left.synthetic_italic == right.synthetic_italic
571}
572
573fn font_query_weight(weight: i32) -> Option<i32> {
574 (weight != 400).then_some(weight)
575}
576
577fn font_style_name(style: &ParsedSpanStyle) -> Option<String> {
578 match (style.bold, style.italic) {
579 (true, true) => Some("Bold Italic".to_string()),
580 (true, false) => Some("Bold".to_string()),
581 (false, true) => Some("Italic".to_string()),
582 (false, false) => None,
583 }
584}
585
586fn normalize_style_index(track: &ParsedTrack, event: &ParsedEvent) -> usize {
587 if track.styles.is_empty() {
588 return 0;
589 }
590
591 let candidate = usize::try_from(event.style).unwrap_or(0);
592 if candidate < track.styles.len() {
593 candidate
594 } else {
595 usize::try_from(track.default_style)
596 .ok()
597 .filter(|index| *index < track.styles.len())
598 .unwrap_or(0)
599 }
600}
601
602fn resolve_margin(event_margin: i32, style_margin: i32) -> i32 {
603 if event_margin == 0 {
604 style_margin
605 } else {
606 event_margin
607 }
608}
609
610fn normalize_justify(justify: i32, alignment: i32) -> i32 {
611 if justify != ass::ASS_JUSTIFY_AUTO {
612 return justify;
613 }
614
615 match alignment & 0x3 {
616 ass::HALIGN_LEFT => ass::ASS_JUSTIFY_LEFT,
617 ass::HALIGN_RIGHT => ass::ASS_JUSTIFY_RIGHT,
618 _ => ass::ASS_JUSTIFY_CENTER,
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use rassa_fonts::{FontconfigProvider, NullFontProvider, font_match_supports_text};
626 use rassa_parse::{ParsedKaraokeMode, ParsedTrack, parse_script_text};
627
628 fn parse_track(input: &str) -> ParsedTrack {
629 parse_script_text(input).expect("script should parse")
630 }
631
632 #[test]
633 fn layout_uses_style_font_and_event_margins() {
634 let track = parse_track(
635 "[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",
636 );
637 let engine = LayoutEngine::new();
638 let provider = NullFontProvider;
639 let layout = engine
640 .layout_track_event(&track, 0, &provider)
641 .expect("layout should succeed");
642
643 assert_eq!(layout.style_index, 1);
644 assert_eq!(layout.font_family, "DejaVu Sans");
645 assert_eq!(layout.margin_l, 30);
646 assert_eq!(layout.margin_r, 22);
647 assert_eq!(layout.margin_v, 40);
648 assert_eq!(layout.lines.len(), 1);
649 assert_eq!(layout.lines[0].glyph_count, "Visible text".chars().count());
650 assert_eq!(layout.lines[0].runs.len(), 1);
651 }
652
653 #[test]
654 fn override_italic_resolves_italic_font_style() {
655 let track = parse_track(
656 "[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",
657 );
658 let engine = LayoutEngine::new();
659 let provider = FontconfigProvider::new();
660 let layout = engine
661 .layout_track_event(&track, 0, &provider)
662 .expect("layout should succeed");
663 let run = layout.lines[0].runs.first().expect("italic run");
664
665 assert!(run.style.italic);
666 assert!(
667 run.font
668 .style
669 .as_deref()
670 .unwrap_or_default()
671 .to_ascii_lowercase()
672 .contains("italic"),
673 "italic override must request an italic font face/style, got {:?}",
674 run.font.style
675 );
676 }
677
678 #[test]
679 fn layout_splits_lines_on_mandatory_breaks() {
680 let mut track = parse_track(
681 "[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",
682 );
683 track.events[0].text = "a\nb".to_string();
684 let engine = LayoutEngine::new();
685 let provider = NullFontProvider;
686 let layout = engine
687 .layout_track_event(&track, 0, &provider)
688 .expect("layout should succeed");
689
690 assert_eq!(layout.lines.len(), 2);
691 assert_eq!(layout.lines[0].text, "a");
692 assert_eq!(layout.lines[1].text, "b");
693 }
694
695 #[test]
696 fn layout_wraps_long_text_at_unicode_line_breaks() {
697 let track = parse_track(
698 "[Script Info]
699PlayResX: 8
700WrapStyle: 0
701
702[V4+ Styles]
703Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
704Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,2,2,0,1
705
706[Events]
707Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
708Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,alpha beta gamma delta",
709 );
710 let engine = LayoutEngine::new();
711 let provider = NullFontProvider;
712 let layout = engine
713 .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
714 .expect("layout should succeed");
715
716 assert!(layout.lines.len() > 1);
717 assert!(layout.lines.iter().all(|line| line.width <= 4.0));
718 assert!(layout.lines.iter().all(|line| !line.text.starts_with(' ')));
719 assert!(layout.lines.iter().all(|line| !line.text.ends_with(' ')));
720 }
721
722 #[test]
723 fn layout_q2_disables_automatic_wrapping() {
724 let track = parse_track(
725 "[Script Info]
726PlayResX: 8
727WrapStyle: 0
728
729[V4+ Styles]
730Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
731Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,2,2,0,1
732
733[Events]
734Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
735Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\q2}alpha beta gamma delta",
736 );
737 let engine = LayoutEngine::new();
738 let provider = NullFontProvider;
739 let layout = engine
740 .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
741 .expect("layout should succeed");
742
743 assert_eq!(layout.lines.len(), 1);
744 assert!(layout.lines[0].width > 4.0);
745 }
746
747 #[test]
748 fn layout_wraps_positioned_center_text_against_margins_not_anchor_space() {
749 let track = parse_track(
750 "[Script Info]
751PlayResX: 40
752WrapStyle: 0
753
754[V4+ Styles]
755Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
756Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,5,2,2,0,1
757
758[Events]
759Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
760Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\pos(10,20)\\an5\\q0}alpha beta gamma delta",
761 );
762 let engine = LayoutEngine::new();
763 let provider = NullFontProvider;
764 let layout = engine
765 .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
766 .expect("layout should succeed");
767
768 assert_eq!(layout.lines.len(), 1);
769 assert_eq!(layout.lines[0].text, "alpha beta gamma delta");
770 }
771
772 #[test]
773 fn layout_wraps_cjk_using_unicode_line_break_opportunities() {
774 let track = parse_track(
775 "[Script Info]
776Language: ja
777PlayResX: 6
778WrapStyle: 0
779
780[V4+ Styles]
781Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
782Style: Default,Arial,8,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,2,2,0,1
783
784[Events]
785Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
786Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,日本語日本語",
787 );
788 let engine = LayoutEngine::new();
789 let provider = NullFontProvider;
790 let layout = engine
791 .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
792 .expect("layout should succeed");
793
794 assert!(layout.lines.len() > 1);
795 assert!(layout.lines.iter().all(|line| line.width <= 2.0));
796 }
797
798 #[test]
799 fn layout_applies_font_override_runs() {
800 let track = parse_track(
801 "[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",
802 );
803 let engine = LayoutEngine::new();
804 let provider = NullFontProvider;
805 let layout = engine
806 .layout_track_event(&track, 0, &provider)
807 .expect("layout should succeed");
808
809 assert_eq!(layout.lines.len(), 1);
810 assert_eq!(layout.lines[0].runs.len(), 2);
811 assert_eq!(layout.lines[0].runs[0].style.font_name, "DejaVu Sans");
812 assert_eq!(layout.lines[0].runs[1].style.font_name, "Arial");
813 }
814
815 #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
816 #[test]
817 fn layout_splits_cjk_text_to_covered_fallback_font_run() {
818 if resolve_system_font_for_char("DejaVu Sans", None, '日').is_none() {
819 eprintln!("skipping: system fontconfig has no CJK-capable fallback font");
820 return;
821 }
822 let track = parse_track(
823 "[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 日本語",
824 );
825 let engine = LayoutEngine::new();
826 let provider = FontconfigProvider::new();
827 let layout = engine
828 .layout_track_event(&track, 0, &provider)
829 .expect("layout should succeed");
830
831 let cjk_run = layout.lines[0]
832 .runs
833 .iter()
834 .find(|run| run.text.contains('日'))
835 .expect("CJK text should be retained in a glyph run");
836 assert!(font_match_supports_text(&cjk_run.font, "日本語"));
837 assert_ne!(cjk_run.font_family, "DejaVu Sans");
838 }
839
840 #[test]
841 fn layout_carries_clip_metadata() {
842 let track = parse_track(
843 "[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",
844 );
845 let engine = LayoutEngine::new();
846 let provider = NullFontProvider;
847 let layout = engine
848 .layout_track_event(&track, 0, &provider)
849 .expect("layout should succeed");
850
851 assert_eq!(
852 layout.clip_rect,
853 Some(Rect {
854 x_min: 10,
855 y_min: 20,
856 x_max: 30,
857 y_max: 40
858 })
859 );
860 assert!(layout.vector_clip.is_none());
861 assert!(layout.inverse_clip);
862 }
863
864 #[test]
865 fn layout_carries_vector_clip_metadata() {
866 let track = parse_track(
867 "[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",
868 );
869 let engine = LayoutEngine::new();
870 let provider = NullFontProvider;
871 let layout = engine
872 .layout_track_event(&track, 0, &provider)
873 .expect("layout should succeed");
874
875 assert!(layout.clip_rect.is_none());
876 assert!(layout.vector_clip.is_some());
877 assert!(!layout.inverse_clip);
878 }
879
880 #[test]
881 fn layout_carries_move_metadata() {
882 let track = parse_track(
883 "[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",
884 );
885 let engine = LayoutEngine::new();
886 let provider = NullFontProvider;
887 let layout = engine
888 .layout_track_event(&track, 0, &provider)
889 .expect("layout should succeed");
890
891 assert_eq!(
892 layout.movement,
893 Some(ParsedMovement {
894 start: (1, 2),
895 end: (3, 4),
896 t1_ms: 50,
897 t2_ms: 150,
898 })
899 );
900 }
901
902 #[test]
903 fn layout_carries_fade_metadata() {
904 let track = parse_track(
905 "[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",
906 );
907 let engine = LayoutEngine::new();
908 let provider = NullFontProvider;
909 let layout = engine
910 .layout_track_event(&track, 0, &provider)
911 .expect("layout should succeed");
912
913 assert_eq!(
914 layout.fade,
915 Some(ParsedFade::Simple {
916 fade_in_ms: 100,
917 fade_out_ms: 200,
918 })
919 );
920 }
921
922 #[test]
923 fn layout_carries_full_fade_metadata() {
924 let track = parse_track(
925 "[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",
926 );
927 let engine = LayoutEngine::new();
928 let provider = NullFontProvider;
929 let layout = engine
930 .layout_track_event(&track, 0, &provider)
931 .expect("layout should succeed");
932
933 assert_eq!(
934 layout.fade,
935 Some(ParsedFade::Complex {
936 alpha1: 10,
937 alpha2: 20,
938 alpha3: 30,
939 t1_ms: 40,
940 t2_ms: 50,
941 t3_ms: 60,
942 t4_ms: 70,
943 })
944 );
945 }
946
947 #[test]
948 fn layout_carries_karaoke_metadata() {
949 let track = parse_track(
950 "[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",
951 );
952 let engine = LayoutEngine::new();
953 let provider = NullFontProvider;
954 let layout = engine
955 .layout_track_event(&track, 0, &provider)
956 .expect("layout should succeed");
957
958 assert_eq!(layout.lines[0].runs.len(), 2);
959 assert_eq!(
960 layout.lines[0].runs[0].karaoke,
961 Some(ParsedKaraokeSpan {
962 start_ms: 0,
963 duration_ms: 100,
964 mode: ParsedKaraokeMode::FillSwap,
965 })
966 );
967 assert_eq!(
968 layout.lines[0].runs[1].karaoke,
969 Some(ParsedKaraokeSpan {
970 start_ms: 100,
971 duration_ms: 200,
972 mode: ParsedKaraokeMode::FillSwap,
973 })
974 );
975 }
976
977 #[test]
978 fn layout_carries_transform_metadata() {
979 let track = parse_track(
980 "[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",
981 );
982 let engine = LayoutEngine::new();
983 let provider = NullFontProvider;
984 let layout = engine
985 .layout_track_event(&track, 0, &provider)
986 .expect("layout should succeed");
987
988 assert_eq!(layout.lines[0].runs[0].transforms.len(), 1);
989 assert_eq!(
990 layout.lines[0].runs[0].transforms[0].style.border,
991 Some(4.0)
992 );
993 assert_eq!(
994 layout.lines[0].runs[0].transforms[0].style.primary_colour,
995 Some(0x0011_2233)
996 );
997 }
998
999 #[test]
1000 fn layout_carries_drawing_runs() {
1001 let track = parse_track(
1002 "[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",
1003 );
1004 let engine = LayoutEngine::new();
1005 let provider = NullFontProvider;
1006 let layout = engine
1007 .layout_track_event(&track, 0, &provider)
1008 .expect("layout should succeed");
1009
1010 assert_eq!(layout.lines[0].runs.len(), 1);
1011 assert!(layout.lines[0].runs[0].drawing.is_some());
1012 assert_eq!(layout.lines[0].runs[0].width, 8.0);
1013 }
1014
1015 #[test]
1016 fn layout_carries_missing_override_metadata() {
1017 let track = parse_track(
1018 "[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",
1019 );
1020 let engine = LayoutEngine::new();
1021 let provider = NullFontProvider;
1022 let layout = engine
1023 .layout_track_event(&track, 0, &provider)
1024 .expect("layout should succeed");
1025
1026 assert_eq!(layout.alignment, ass::VALIGN_CENTER | ass::HALIGN_CENTER);
1027 assert_eq!(layout.wrap_style, Some(2));
1028 assert_eq!(layout.origin, Some((320, 240)));
1029 let style = &layout.lines[0].runs[0].style;
1030 assert!(style.underline);
1031 assert!(style.strike_out);
1032 assert_eq!(style.rotation_x, 12.0);
1033 assert_eq!(style.rotation_y, -8.0);
1034 assert_eq!(style.shear_x, 0.25);
1035 assert_eq!(style.shear_y, -0.5);
1036 assert_eq!(style.border_x, 3.0);
1037 assert_eq!(style.border_y, 4.0);
1038 assert_eq!(style.shadow_x, 5.0);
1039 assert_eq!(style.shadow_y, -6.0);
1040 assert_eq!(style.be, 2.0);
1041 assert_eq!(style.pbo, 7.0);
1042 }
1043
1044 #[test]
1045 fn layout_accepts_explicit_shaping_mode() {
1046 let track = parse_track(
1047 "[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",
1048 );
1049 let engine = LayoutEngine::new();
1050 let provider = FontconfigProvider::new();
1051 let simple = engine
1052 .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Simple)
1053 .expect("simple layout should succeed");
1054 let complex = engine
1055 .layout_track_event_with_mode(&track, 0, &provider, ShapingMode::Complex)
1056 .expect("complex layout should succeed");
1057
1058 assert_eq!(simple.lines.len(), 1);
1059 assert_eq!(complex.lines.len(), 1);
1060 assert_eq!(simple.lines[0].text, "office");
1061 assert_eq!(complex.lines[0].text, "office");
1062 }
1063}