1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
//! Text-segment layout for the software pipeline: resolving style defaults,
//! per-run colours/alpha/fade, effects, and the positioned `TextData` layers.
#[cfg(feature = "nostd")]
use alloc::{string::ToString, vec::Vec};
#[cfg(not(feature = "nostd"))]
use std::{string::ToString, vec::Vec};
use ass_core::parser::Event;
use smallvec::SmallVec;
use super::OwnedStyle;
use crate::pipeline::{
shaping::shape_text_cached, tag_processor::KaraokeStyle, text_segmenter::TextSegment,
IntermediateLayer, TextData, TextEffect,
};
use crate::renderer::RenderContext;
use crate::utils::RenderError;
mod colors;
mod defaults;
mod effects;
mod position;
mod types;
use colors::{resolve_run_colors, split_into_logical_lines};
use types::{EffectColors, LineLayout, Pen, RunCtx};
impl super::SoftwarePipeline {
pub(super) fn process_text_segments(
&mut self,
segments: Vec<TextSegment>,
event: &Event,
style: Option<&OwnedStyle>,
time_cs: u32,
context: &RenderContext,
) -> Result<Vec<IntermediateLayer>, RenderError> {
let mut all_layers = Vec::new();
// Calculate scaling factors for sizes
let scale_x = context.width() as f32 / self.play_res_x;
let scale_y = context.height() as f32 / self.play_res_y;
// Resolve the style-derived defaults (font/size/colours/formatting) once.
let defaults = self.resolve_text_defaults(style, scale_y);
// Get base position from first segment - we'll adjust per-segment as needed
let _base_tags = &segments[0].tags;
// Effective wrap style: a `\q` override (clamped to 0..=3, like libass which
// falls back to the track style on an invalid value) takes precedence over
// the script's WrapStyle header.
let event_wrap_style = segments
.iter()
.find_map(|s| s.tags.formatting.wrap_style)
.filter(|&q| q <= 3)
.unwrap_or(self.wrap_style);
// Restructure segments into logical lines
let mut logical_lines = split_into_logical_lines(segments);
// Width-based auto-wrap. WrapStyle 2 disables width wrapping entirely (only
// explicit \N breaks, already split into logical_lines); positioned events
// (\pos/\move) keep their explicit layout. WrapStyle 1 wraps greedily with
// no balancing; 0/3 balance the lines (libass smart wrapping).
if !Self::event_is_positioned(event) && event_wrap_style != 2 {
let margin_l =
Self::margin_or_style(event.margin_l, style.map(|s| s.margin_l.as_str()));
let margin_r =
Self::margin_or_style(event.margin_r, style.map(|s| s.margin_r.as_str()));
let available = (context.width() as f32 - (margin_l + margin_r) * scale_x).max(1.0);
let balance = event_wrap_style != 1;
let mut wrapped: Vec<Vec<TextSegment>> = Vec::with_capacity(logical_lines.len());
for line in logical_lines {
wrapped.extend(self.wrap_segments(
&line,
defaults.font_name,
defaults.font_size_base,
defaults.scale_y,
defaults.bold,
defaults.italic,
defaults.spacing,
defaults.scale_x,
scale_y,
available,
balance,
));
}
logical_lines = wrapped;
}
// Calculate total height for multi-line text
let num_lines = logical_lines.len();
// For line height in libass:
// - Line spacing uses the font size in script resolution scaled to render resolution
// - DPI scaling affects glyph rendering but NOT line spacing
// - ScaleY affects glyph size but NOT line spacing
let line_spacing_multiplier = 1.0; // Match libass line spacing
// Line height for spacing between lines (not affected by the glyph DPI
// scale in libass). libass advances by the line's font size, so use the
// largest font size across the event (covering inline `\fs` overrides)
// rather than only the style default, which packed large-`\fs` lines too
// tightly.
let max_font_size = logical_lines
.iter()
.flatten()
.filter_map(|seg| seg.tags.font.size)
.fold(defaults.font_size_base, f32::max);
let estimated_line_height = max_font_size * scale_y;
let _total_text_height = estimated_line_height * num_lines as f32 * line_spacing_multiplier;
// Process each logical line
let mut line_y_offset = 0.0;
let run_ctx = RunCtx {
event,
context,
time_cs,
scale_x,
scale_y,
};
for (line_index, line_segments) in logical_lines.into_iter().enumerate() {
// For a multi-segment line, total rendered width so it is aligned as
// one unit rather than each segment re-centering on its own width. A
// single-segment line uses its per-segment shaped width below (which
// already reflects \t animations), so skip the pre-pass there.
let is_multi_segment = line_segments.len() > 1;
let line_total_width: f32 = if is_multi_segment {
line_segments
.iter()
.map(|seg| {
let size = seg.tags.font.size.unwrap_or(defaults.font_size_base)
* scale_y
* (seg.tags.font.scale_y.unwrap_or(defaults.scale_y) / 100.0)
* self.dpi_scale;
let fsx = seg.tags.font.scale_x.unwrap_or(defaults.scale_x) / 100.0;
let font = seg.tags.font.name.as_deref().unwrap_or(defaults.font_name);
let bold = seg.tags.formatting.bold.unwrap_or(defaults.bold);
let italic = seg.tags.formatting.italic.unwrap_or(defaults.italic);
shape_text_cached(&seg.text, font, size, bold, italic, &self.font_database)
.map_or(0.0, |sh| sh.width * fsx)
})
.sum()
} else {
0.0
};
let mut pen = Pen {
current_x: 0.0,
needs_initial_position: true,
};
let mut karaoke_accumulated_time = 0u32; // Track cumulative karaoke time for this line
let layout = LineLayout {
is_multi_segment,
line_total_width,
num_lines,
line_index,
estimated_line_height,
line_spacing_multiplier,
line_y_offset,
};
for segment in line_segments {
let mut tags = segment.tags.clone();
let line_text = &segment.text;
// Check if this segment is in drawing mode
if let Some(drawing_mode) = tags.drawing_mode {
if drawing_mode > 0 {
// Process drawing commands
if let Ok(drawing_layers) =
self.process_drawing_command(&segment, event, style, time_cs, context)
{
for layer in drawing_layers {
all_layers.push(layer);
}
}
continue; // Skip text processing for drawing segments
}
}
// Apply transform animations if present
let event_start_cs = event.start_time_cs().unwrap_or(0);
let default_colors = (
defaults.primary_color,
defaults.secondary_color,
defaults.outline_color,
defaults.back_color,
);
self.apply_transform_animations(
&mut tags,
event_start_cs,
event.end_time_cs().unwrap_or(u32::MAX),
time_cs,
default_colors,
defaults.font_size_base,
);
// Shape the text first to get dimensions for proper alignment
// Get base font size and scale factors
let base_font_size = tags.font.size.unwrap_or(defaults.font_size_base); // Use unscaled base size
let font_scale_x = tags.font.scale_x.unwrap_or(defaults.scale_x) / 100.0;
let font_scale_y = tags.font.scale_y.unwrap_or(defaults.scale_y) / 100.0;
// Apply both resolution scaling, percentage scaling, and DPI scaling
let actual_font_size = base_font_size * scale_y * font_scale_y * self.dpi_scale;
let font_to_use = tags.font.name.as_deref().unwrap_or(defaults.font_name);
let shaped = shape_text_cached(
line_text,
font_to_use,
actual_font_size,
tags.formatting.bold.unwrap_or(defaults.bold),
tags.formatting.italic.unwrap_or(defaults.italic),
&self.font_database,
)?;
// Calculate position for this segment
let (segment_x, segment_y) = self.position_segment(
&tags,
&shaped,
&layout,
&mut pen,
&run_ctx,
defaults.alignment,
);
// Get font information with proper inheritance
let font_family = tags.font.name.as_deref().unwrap_or(defaults.font_name);
// Font size was already calculated above for shaping
// Use the same value here for consistency
let font_size = actual_font_size;
// Note: 'shaped' was already created above for alignment calculation
// Resolve colours, alpha overrides, and fade for this run.
let (color, outline_color, shadow_color) =
resolve_run_colors(&tags, &defaults, &run_ctx);
// Get spacing value (from tags or default style). `\fsp`/style
// spacing is in script units; scale it to screen like positions so
// the rendered run width (and thus wrapping) matches libass, which
// applies fsp * scale. Unscaled, spaced text drew ~2% too wide.
let spacing = tags.font.spacing.unwrap_or(defaults.spacing) * scale_x;
// Create text layer
let mut layer = TextData {
text: line_text.to_string(),
font_family: font_family.to_string(),
font_size,
color,
x: segment_x,
y: segment_y,
effects: SmallVec::new(),
spacing,
};
// Add effects (formatting, outline/shadow/blur/opaque-box, rotation,
// shear, scale, clip, baseline offset).
self.push_text_effects(
&mut layer,
&tags,
EffectColors {
outline_color,
shadow_color,
},
&run_ctx,
&defaults,
style,
);
// Handle karaoke - track per-syllable timing
if let Some(karaoke) = &tags.karaoke {
// Calculate progress for THIS syllable based on cumulative timing
let syllable_start =
event.start_time_cs().unwrap_or(0) + karaoke_accumulated_time;
let syllable_end = syllable_start + karaoke.duration;
let progress = if time_cs < syllable_start {
0.0 // Not yet started
} else if time_cs >= syllable_end {
1.0 // Fully highlighted
} else {
// In progress
(time_cs - syllable_start) as f32 / karaoke.duration as f32
};
// Unsung syllables use the secondary colour. `\2c` overrides are
// 6-digit (no alpha), so inherit alpha from the style default.
let mut karaoke_secondary =
tags.colors.secondary.unwrap_or(defaults.secondary_color);
karaoke_secondary[3] = defaults.secondary_color[3];
// Add karaoke effect with correct progress
layer.effects.push(TextEffect::Karaoke {
progress,
style: match karaoke.style {
KaraokeStyle::Basic => 0,
KaraokeStyle::Fill => 1,
KaraokeStyle::Outline => 2,
KaraokeStyle::Sweep => 3,
},
secondary: karaoke_secondary,
});
// Accumulate time for next syllable
karaoke_accumulated_time += karaoke.duration;
}
// Advance the pen to the end of this segment so the next run on the
// line continues right after it. Always use the real shaped advance:
// the old character-count estimate (used whenever alignment was set,
// including inherited `\an`) over-advanced and left gaps between the
// runs of a multi-segment line.
if let Some(advance) = shaped.total_advance() {
pen.current_x += advance * font_scale_x;
}
all_layers.push(IntermediateLayer::Text(layer));
}
// Move to next line. Advance by the nominal line height (font size in
// render resolution), matching libass's baseline-to-baseline spacing.
// `line_height` carries the 0.9 glyph dpi_scale, which libass does not
// apply to line advance, so it must not be used here.
line_y_offset += estimated_line_height * line_spacing_multiplier;
}
Ok(all_layers)
}
}