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