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
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
//! Per-run setup for the software text layer: shape the text, build the base
//! transform, take the cached coverage / blur-tile fast paths, build and project
//! the glyph paths, and resolve the clip mask, blur/outline/shadow/karaoke info,
//! merged outline and fill paint into a [`TextRun`].
use tiny_skia::Transform;
use crate::backends::geometry::{merge_transformed, project_path_3d};
use crate::pipeline::TextData;
use crate::utils::RenderError;
#[cfg(not(feature = "nostd"))]
use super::super::dirty::{note_dirty_bbox, text_vector_dirty_bbox};
use super::TextRun;
impl super::super::SoftwareBackend {
/// Resolve the shared per-run drawing state from `data`: shape the text,
/// build the base transform (rotation/scale/shear), take the cached coverage
/// and blur-tile fast paths (returning `None` when a fast path already
/// composited the layer), build the glyph paths (projecting them for
/// `\frx`/`\fry`), and extract the clip mask, blur/outline/shadow info, merged
/// outline, fill paint and karaoke parameters.
pub(super) fn prepare_text_run(
&mut self,
data: &TextData,
) -> Result<Option<TextRun>, RenderError> {
use crate::pipeline::shaping::{find_font_for_text, shape_text_cached};
// Extract bold/italic from effects
let bold = data
.effects
.iter()
.any(|e| matches!(e, crate::pipeline::TextEffect::Bold));
let italic = data
.effects
.iter()
.any(|e| matches!(e, crate::pipeline::TextEffect::Italic));
let underline = data
.effects
.iter()
.any(|e| matches!(e, crate::pipeline::TextEffect::Underline));
let strikethrough = data
.effects
.iter()
.any(|e| matches!(e, crate::pipeline::TextEffect::Strikethrough));
// Shape the text via the shared per-thread cache (persists across frames
// and reuses the run the pipeline already shaped for layout).
let shaped = shape_text_cached(
&data.text,
&data.font_family,
data.font_size,
bold,
italic,
&self.font_database,
)?;
// Build base transform with rotation and scaling
// The data.x and data.y are the top-left corner of the text box
// But glyphs are positioned from their baseline, so we need to adjust y by adding the baseline offset
let baseline_y = data.y + shaped.baseline;
let mut base_transform = Transform::from_translate(data.x, baseline_y);
// \frx/\fry need a true projective transform; record (frx_rad, fry_rad,
// local rotation centre) here and project the glyph paths per-point below.
let mut rot3d: Option<(f32, f32, f32, f32)> = None;
// Check for rotation, scaling, and shear effects
for effect in &data.effects {
match effect {
crate::pipeline::TextEffect::Rotation { x, y, z, origin } => {
// Rotations are applied around a centre in local space. By
// default that is the text's own centre; `\org` overrides it with
// an explicit screen-space point (converted to local coords).
// Doing this in local space keeps the glyphs in place; the skews
// used to approximate \frx/\fry previously sheared around the
// screen origin, which flung the text off-frame.
// Local origin sits on the baseline (see base_transform), so the
// text's vertical centre is `height/2 - baseline` above it (matching
// the Scale effect); using `height/2` rotated about a point ~one
// ascent too low.
let (text_center_x, text_center_y) = match origin {
Some((ox, oy)) => (ox - data.x, oy - baseline_y),
None => (shaped.width / 2.0, shaped.height / 2.0 - shaped.baseline),
};
// Z rotation (true 2D rotation). tiny-skia's pre_rotate takes
// DEGREES and turns clockwise in screen space, but ASS `\frz` is
// counter-clockwise for positive angles, so negate to match libass.
if *z != 0.0 {
base_transform = base_transform
.pre_translate(text_center_x, text_center_y)
.pre_rotate(-*z)
.pre_translate(-text_center_x, -text_center_y);
}
// \frx/\fry are a true perspective projection (libass divides by
// a camera distance), which tiny-skia's affine Transform cannot
// express. Record the angles + the local rotation centre; the
// glyph paths are projected per-point below. \frz stays affine and
// is applied first, matching libass's RZ->RX->RY order.
if *x != 0.0 || *y != 0.0 {
rot3d = Some((
x * core::f32::consts::PI / 180.0,
y * core::f32::consts::PI / 180.0,
text_center_x,
text_center_y,
));
}
}
crate::pipeline::TextEffect::Scale { x, y } => {
// `\fscy` is baked into the font SIZE during shaping, so the glyph
// arrives scaled by the y-factor on BOTH axes. libass scales the
// axes independently, so here we only correct the HORIZONTAL one:
// dividing `\fscx` by `\fscy` makes the net horizontal scale equal
// `\fscx` while the vertical stays `\fscy`. (For `\fscx` alone, y is
// 100 so this reduces to the plain x-scale; for uniform scaling the
// ratio is 1 and this is a no-op.)
let x_scale = if *y != 0.0 { *x / *y } else { *x / 100.0 };
if (x_scale - 1.0).abs() > 0.01 {
let text_center_x = shaped.width / 2.0;
base_transform = base_transform
.pre_translate(text_center_x, 0.0)
.pre_scale(x_scale, 1.0)
.pre_translate(-text_center_x, 0.0);
}
}
crate::pipeline::TextEffect::Shear { x, y } => {
// Apply shear (\fax/\fay) around the text centre. Shearing around
// the screen origin displaced the text by skew*position, shoving
// it far across the frame.
let text_center_x = shaped.width / 2.0;
let text_center_y = shaped.height / 2.0;
base_transform = base_transform
.pre_translate(text_center_x, text_center_y)
.pre_concat(Transform::from_skew(*x, *y))
.pre_translate(-text_center_x, -text_center_y);
}
_ => {}
}
}
// Fast path: composite the layer from cached coverage tiles. On a cache
// hit this skips font lookup, glyph-path building and rasterization
// entirely, so a geometry-static animated layer (\move/\fad/colour \t)
// costs only the composite — the lever for animation-heavy content.
#[cfg(not(feature = "nostd"))]
if rot3d.is_none() && self.coverage_hit(data, base_transform, baseline_y) {
return Ok(None);
}
// Fast path for blurred text whose bitmap is already cached: composite it
// and skip the font lookup + glyph-path build entirely. On a blur-cache hit
// those paths are unused (the blur branch just composites the cached tile),
// so this is bit-identical — it just avoids the per-call font parse and
// outline build, the dominant cost of the recurring blurred credit glyphs.
#[cfg(not(feature = "nostd"))]
if rot3d.is_none() && self.blur_tile_hit(data, bold, italic, baseline_y, shaped.ascent) {
return Ok(None);
}
// Cache miss (or an effect the coverage path does not handle): resolve the
// font and build the glyph paths now.
let font_id = find_font_for_text(
&self.font_database,
&data.font_family,
bold,
italic,
&data.text,
)?;
let paths = self.glyph_renderer.render_shaped_text(
&shaped,
font_id,
&self.font_database,
data.spacing,
)?;
// For \frx/\fry, project the positioned glyph paths through the perspective
// transform once and switch to an identity base transform, so the vector
// fills below operate on the already-projected screen-space outlines. The
// perspective camera distance is libass's 20000 in 1/64-px units.
#[cfg(not(feature = "nostd"))]
let (paths, base_transform) = if let Some((frx, fry, lcx, lcy)) = rot3d {
let mut center = [tiny_skia::Point::from_xy(lcx, lcy)];
base_transform.map_points(&mut center);
let dist = 20000.0 / 64.0;
let projected: Vec<tiny_skia::Path> = paths
.iter()
.filter_map(|p| {
let screen = p.clone().transform(base_transform)?;
project_path_3d(&screen, frx, fry, center[0].x, center[0].y, dist)
})
.collect();
(projected, Transform::identity())
} else {
(paths, base_transform)
};
// Rasterize, cache and composite the coverage. Returns false only for
// effects the coverage path does not handle, which fall through to the
// full vector path below. Skipped for 3D (the projected paths are not
// representable by the affine coverage cache).
#[cfg(not(feature = "nostd"))]
if rot3d.is_none() && self.rasterize_coverage_miss(data, &paths, base_transform, baseline_y)
{
return Ok(None);
}
// Vector path (blur / swept karaoke / clip / opaque box / under-strike).
// Record a generous dirty bbox so a bitmap-list crop scans only this region.
#[cfg(not(feature = "nostd"))]
if let Some(bbox) = text_vector_dirty_bbox(data, &paths, base_transform) {
note_dirty_bbox(bbox);
}
// Create clip mask if needed
let clip_mask = data.effects.iter().find_map(|e| {
if let crate::pipeline::TextEffect::Clip {
x1,
y1,
x2,
y2,
inverse,
} = e
{
// Create a mask for clipping
let width = self.pixmap.width();
let height = self.pixmap.height();
if let Some(mut mask) = tiny_skia::Mask::new(width, height) {
let mut builder = tiny_skia::PathBuilder::new();
// The clip rectangle itself.
builder.move_to(*x1, *y1);
builder.line_to(*x2, *y1);
builder.line_to(*x2, *y2);
builder.line_to(*x1, *y2);
builder.close();
// For \iclip, also add a full-canvas rectangle and use the
// even-odd rule so coverage is left *outside* the clip rect
// (the rect ends up with winding count 2 => uncovered).
let fill_rule = if *inverse {
builder.move_to(0.0, 0.0);
builder.line_to(width as f32, 0.0);
builder.line_to(width as f32, height as f32);
builder.line_to(0.0, height as f32);
builder.close();
tiny_skia::FillRule::EvenOdd
} else {
tiny_skia::FillRule::Winding
};
if let Some(clip_path) = builder.finish() {
mask.fill_path(&clip_path, fill_rule, true, Transform::identity());
return Some(mask);
}
}
None
} else {
None
}
});
// \blur radius, outline and shadow, detected up front. When \blur is
// active the outline and shadow are rendered into the blur temp (below)
// and softened together with the fill, rather than drawn sharp here.
let blur_radius = data.effects.iter().find_map(|e| {
if let crate::pipeline::TextEffect::Blur { radius } = e {
Some(*radius)
} else {
None
}
});
let outline_info = data.effects.iter().find_map(|e| {
if let crate::pipeline::TextEffect::Outline {
color,
width_x,
width_y,
} = e
{
Some((*color, *width_x, *width_y))
} else {
None
}
});
let shadow_info = data.effects.iter().find_map(|e| {
if let crate::pipeline::TextEffect::Shadow {
color,
x_offset,
y_offset,
} = e
{
Some((*color, *x_offset, *y_offset))
} else {
None
}
});
// Merge the layer's positioned glyph outlines once (sharp/non-\blur path):
// the sharp shadow, outline and main fill each rasterize this single path
// in one pass instead of looping per glyph — the dominant per-frame cost on
// animated scenes. The \blur branch keeps its own per-glyph temp pixmap.
let merged_base = if blur_radius.is_none() {
merge_transformed(&paths, base_transform)
} else {
None
};
// Check for edge blur effect (applies to outline only)
let edge_blur_radius = data.effects.iter().find_map(|e| {
if let crate::pipeline::TextEffect::EdgeBlur { radius } = e {
Some(*radius)
} else {
None
}
});
// Draw main text
let mut text_paint = tiny_skia::Paint::default();
text_paint.set_color_rgba8(data.color[0], data.color[1], data.color[2], data.color[3]);
text_paint.anti_alias = true;
text_paint.blend_mode = tiny_skia::BlendMode::SourceOver;
// Check for karaoke effect
let karaoke_info = data.effects.iter().find_map(|e| {
if let crate::pipeline::TextEffect::Karaoke {
progress,
style,
secondary,
} = e
{
Some((*progress, *style, *secondary))
} else {
None
}
});
Ok(Some(TextRun {
paths,
base_transform,
baseline_y,
shaped,
clip_mask,
blur_radius,
outline_info,
shadow_info,
edge_blur_radius,
karaoke_info,
merged_base,
text_paint,
bold,
italic,
underline,
strikethrough,
}))
}
}