Skip to main content

cranpose_render_common/
software_text_raster.rs

1use ab_glyph::{point, Font, Glyph, OutlinedGlyph, ScaleFont};
2use cranpose_ui::text::{Shadow, TextDrawStyle, TextMotion, TextStyle};
3use cranpose_ui_graphics::{Color, ImageBitmap, Rect, TileMode};
4use tiny_skia::{LineCap, LineJoin, Paint, Path, PathBuilder, Pixmap, Stroke, Transform};
5
6use crate::font_layout::{
7    align_glyph_to_pixel_grid, layout_line_glyphs, pixel_bounds_from_outlined, vertical_metrics,
8    GlyphPixelBounds,
9};
10use crate::Brush;
11
12const COMPOSE_STROKE_MITER_LIMIT: f32 = 4.0;
13const SHADOW_SIGMA_SCALE: f32 = 0.57735;
14const SHADOW_SIGMA_BIAS: f32 = 0.5;
15const MAX_GAUSSIAN_KERNEL_HALF: i32 = 128;
16
17#[derive(Clone, Copy)]
18enum GlyphRasterStyle {
19    Fill,
20    Stroke { width_px: f32 },
21}
22
23struct GlyphMask {
24    alpha: Vec<f32>,
25    width: usize,
26    height: usize,
27    origin_x: i32,
28    origin_y: i32,
29}
30
31pub fn rasterize_text_to_image_with_font(
32    text: &str,
33    rect: Rect,
34    style: &TextStyle,
35    fallback_color: Color,
36    font_size: f32,
37    scale: f32,
38    font: &impl Font,
39) -> Option<ImageBitmap> {
40    if text.is_empty()
41        || rect.width <= 0.0
42        || rect.height <= 0.0
43        || !font_size.is_finite()
44        || font_size <= 0.0
45        || !scale.is_finite()
46        || scale <= 0.0
47    {
48        return None;
49    }
50
51    let width = rect.width.ceil().max(1.0) as u32;
52    let height = rect.height.ceil().max(1.0) as u32;
53    let mut canvas = vec![[0.0f32; 4]; (width * height) as usize];
54
55    let fallback_brush = Brush::solid(fallback_color);
56    let (brush, brush_alpha_multiplier) = match style.span_style.brush.as_ref() {
57        Some(brush) => (brush, style.span_style.alpha.unwrap_or(1.0).clamp(0.0, 1.0)),
58        None => (&fallback_brush, 1.0),
59    };
60    let raster_style = match style.span_style.draw_style.unwrap_or(TextDrawStyle::Fill) {
61        TextDrawStyle::Fill => GlyphRasterStyle::Fill,
62        TextDrawStyle::Stroke { width } => {
63            if width.is_finite() && width > 0.0 {
64                GlyphRasterStyle::Stroke {
65                    width_px: width * scale,
66                }
67            } else {
68                GlyphRasterStyle::Fill
69            }
70        }
71    };
72    let shadow = style
73        .span_style
74        .shadow
75        .filter(|shadow| shadow.color.3 > 0.0);
76    let static_text_motion = style
77        .paragraph_style
78        .text_motion
79        .unwrap_or(TextMotion::Static)
80        == TextMotion::Static;
81
82    let origin_x = if static_text_motion {
83        0.0
84    } else {
85        rect.x.fract()
86    };
87    let origin_y = if static_text_motion {
88        0.0
89    } else {
90        rect.y.fract()
91    };
92
93    let font_px_size = font_size * scale;
94    let metrics = vertical_metrics(font, font_px_size);
95    let line_height = style
96        .resolve_line_height(14.0, metrics.natural_line_height)
97        .max(1.0);
98
99    for (line_idx, line) in text.split('\n').enumerate() {
100        let baseline_y = metrics.ascent + line_idx as f32 * line_height + origin_y;
101        let offset = point(origin_x, baseline_y);
102
103        for glyph in layout_line_glyphs(font, line, font_px_size, offset) {
104            let glyph = align_glyph_for_text_motion(glyph, static_text_motion);
105            let Some((outlined, bounds)) = outline_glyph_with_bounds(font, &glyph) else {
106                continue;
107            };
108            let Some(mask) = build_glyph_mask(font, &glyph, &outlined, bounds, raster_style) else {
109                continue;
110            };
111
112            if let Some(shadow) = shadow {
113                draw_shadow_mask(
114                    &mut canvas,
115                    width,
116                    height,
117                    &mask,
118                    shadow,
119                    scale,
120                    static_text_motion,
121                );
122            }
123
124            draw_mask_glyph(
125                &mut canvas,
126                width,
127                height,
128                &mask,
129                brush,
130                brush_alpha_multiplier,
131                rect,
132            );
133        }
134    }
135
136    let mut rgba = vec![0u8; canvas.len() * 4];
137    for (index, pixel) in canvas.iter().enumerate() {
138        let base = index * 4;
139        rgba[base] = (pixel[0].clamp(0.0, 1.0) * 255.0).round() as u8;
140        rgba[base + 1] = (pixel[1].clamp(0.0, 1.0) * 255.0).round() as u8;
141        rgba[base + 2] = (pixel[2].clamp(0.0, 1.0) * 255.0).round() as u8;
142        rgba[base + 3] = (pixel[3].clamp(0.0, 1.0) * 255.0).round() as u8;
143    }
144
145    ImageBitmap::from_rgba8(width, height, rgba).ok()
146}
147
148fn align_glyph_for_text_motion(glyph: Glyph, static_text_motion: bool) -> Glyph {
149    align_glyph_to_pixel_grid(glyph, static_text_motion)
150}
151
152fn blend_src_over(dst: &mut [f32; 4], src: [f32; 4]) {
153    let src_alpha = src[3].clamp(0.0, 1.0);
154    if src_alpha <= 0.0 {
155        return;
156    }
157
158    let dst_alpha = dst[3].clamp(0.0, 1.0);
159    let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
160
161    if out_alpha <= f32::EPSILON {
162        *dst = [0.0, 0.0, 0.0, 0.0];
163        return;
164    }
165
166    for channel in 0..3 {
167        let src_premult = src[channel].clamp(0.0, 1.0) * src_alpha;
168        let dst_premult = dst[channel].clamp(0.0, 1.0) * dst_alpha;
169        dst[channel] =
170            ((src_premult + dst_premult * (1.0 - src_alpha)) / out_alpha).clamp(0.0, 1.0);
171    }
172    dst[3] = out_alpha;
173}
174
175fn draw_mask_glyph(
176    canvas: &mut [[f32; 4]],
177    width: u32,
178    height: u32,
179    mask: &GlyphMask,
180    brush: &Brush,
181    brush_alpha_multiplier: f32,
182    brush_rect: Rect,
183) {
184    for y in 0..mask.height {
185        let py = mask.origin_y + y as i32;
186        if py < 0 || py >= height as i32 {
187            continue;
188        }
189
190        for x in 0..mask.width {
191            let px = mask.origin_x + x as i32;
192            if px < 0 || px >= width as i32 {
193                continue;
194            }
195
196            let coverage = mask.alpha[y * mask.width + x];
197            if coverage <= 0.0 {
198                continue;
199            }
200
201            let sample = sample_brush(
202                brush,
203                brush_rect,
204                brush_rect.x + px as f32 + 0.5,
205                brush_rect.y + py as f32 + 0.5,
206            );
207            let alpha = coverage * sample[3] * brush_alpha_multiplier;
208            if alpha <= 0.0 {
209                continue;
210            }
211            let idx = (py as u32 * width + px as u32) as usize;
212            blend_src_over(
213                &mut canvas[idx],
214                [sample[0], sample[1], sample[2], alpha.clamp(0.0, 1.0)],
215            );
216        }
217    }
218}
219
220fn draw_shadow_mask(
221    canvas: &mut [[f32; 4]],
222    width: u32,
223    height: u32,
224    mask: &GlyphMask,
225    shadow: Shadow,
226    text_scale: f32,
227    static_text_motion: bool,
228) {
229    if mask.width == 0 || mask.height == 0 {
230        return;
231    }
232
233    let shadow_dx = shadow.offset.x * text_scale;
234    let shadow_dy = shadow.offset.y * text_scale;
235    let blur_radius = (shadow.blur_radius * text_scale).max(0.0);
236    let sigma = shadow_blur_sigma(blur_radius);
237    let blur_margin = if sigma > 0.0 {
238        (sigma * 3.0).ceil() as i32
239    } else {
240        0
241    };
242
243    let padded_width = mask.width + (blur_margin as usize) * 2;
244    let padded_height = mask.height + (blur_margin as usize) * 2;
245    let mut padded_mask = vec![0.0f32; padded_width * padded_height];
246
247    for y in 0..mask.height {
248        let src_offset = y * mask.width;
249        let dst_offset = (y + blur_margin as usize) * padded_width + blur_margin as usize;
250        padded_mask[dst_offset..dst_offset + mask.width]
251            .copy_from_slice(&mask.alpha[src_offset..src_offset + mask.width]);
252    }
253
254    let blurred = if sigma > 0.0 {
255        gaussian_blur_alpha(&padded_mask, padded_width, padded_height, sigma)
256    } else {
257        padded_mask
258    };
259
260    let shadow_rgba = color_to_rgba(shadow.color);
261    let shadow_origin_x = mask.origin_x - blur_margin;
262    let shadow_origin_y = mask.origin_y - blur_margin;
263
264    for y in 0..padded_height {
265        for x in 0..padded_width {
266            let alpha = blurred[y * padded_width + x] * shadow_rgba[3];
267            if alpha <= 0.0 {
268                continue;
269            }
270
271            let target_x = shadow_origin_x as f32 + x as f32 + shadow_dx;
272            let target_y = shadow_origin_y as f32 + y as f32 + shadow_dy;
273            if static_text_motion {
274                blend_shadow_pixel(
275                    canvas,
276                    width,
277                    height,
278                    target_x.round() as i32,
279                    target_y.round() as i32,
280                    shadow_rgba,
281                    alpha.clamp(0.0, 1.0),
282                );
283            } else {
284                blend_shadow_pixel_subpixel(
285                    canvas,
286                    width,
287                    height,
288                    target_x,
289                    target_y,
290                    shadow_rgba,
291                    alpha.clamp(0.0, 1.0),
292                );
293            }
294        }
295    }
296}
297
298fn blend_shadow_pixel(
299    canvas: &mut [[f32; 4]],
300    width: u32,
301    height: u32,
302    px: i32,
303    py: i32,
304    color: [f32; 4],
305    alpha: f32,
306) {
307    if px < 0 || py < 0 || px >= width as i32 || py >= height as i32 || alpha <= 0.0 {
308        return;
309    }
310    let idx = (py as u32 * width + px as u32) as usize;
311    blend_src_over(
312        &mut canvas[idx],
313        [color[0], color[1], color[2], alpha.clamp(0.0, 1.0)],
314    );
315}
316
317fn blend_shadow_pixel_subpixel(
318    canvas: &mut [[f32; 4]],
319    width: u32,
320    height: u32,
321    x: f32,
322    y: f32,
323    color: [f32; 4],
324    alpha: f32,
325) {
326    if alpha <= 0.0 {
327        return;
328    }
329
330    let base_x = x.floor();
331    let base_y = y.floor();
332    let frac_x = x - base_x;
333    let frac_y = y - base_y;
334    let base_x_i32 = base_x as i32;
335    let base_y_i32 = base_y as i32;
336    let weights = [
337        ((1.0 - frac_x) * (1.0 - frac_y), 0i32, 0i32),
338        (frac_x * (1.0 - frac_y), 1, 0),
339        ((1.0 - frac_x) * frac_y, 0, 1),
340        (frac_x * frac_y, 1, 1),
341    ];
342
343    for (weight, dx, dy) in weights {
344        if weight <= 0.0 {
345            continue;
346        }
347        blend_shadow_pixel(
348            canvas,
349            width,
350            height,
351            base_x_i32 + dx,
352            base_y_i32 + dy,
353            color,
354            alpha * weight,
355        );
356    }
357}
358
359fn shadow_blur_sigma(blur_radius: f32) -> f32 {
360    if blur_radius <= 0.0 {
361        0.0
362    } else {
363        (blur_radius * SHADOW_SIGMA_SCALE + SHADOW_SIGMA_BIAS).max(0.5)
364    }
365}
366
367fn gaussian_blur_alpha(src: &[f32], width: usize, height: usize, sigma: f32) -> Vec<f32> {
368    let kernel = gaussian_kernel_1d(sigma);
369    if kernel.len() == 1 {
370        return src.to_vec();
371    }
372    let half = (kernel.len() / 2) as i32;
373
374    let mut horizontal = vec![0.0f32; src.len()];
375    for y in 0..height {
376        for x in 0..width {
377            let mut sum = 0.0f32;
378            for (index, weight) in kernel.iter().enumerate() {
379                let offset = index as i32 - half;
380                let sample_x = (x as i32 + offset).clamp(0, width as i32 - 1) as usize;
381                sum += src[y * width + sample_x] * *weight;
382            }
383            horizontal[y * width + x] = sum;
384        }
385    }
386
387    let mut output = vec![0.0f32; src.len()];
388    for y in 0..height {
389        for x in 0..width {
390            let mut sum = 0.0f32;
391            for (index, weight) in kernel.iter().enumerate() {
392                let offset = index as i32 - half;
393                let sample_y = (y as i32 + offset).clamp(0, height as i32 - 1) as usize;
394                sum += horizontal[sample_y * width + x] * *weight;
395            }
396            output[y * width + x] = sum;
397        }
398    }
399
400    output
401}
402
403fn gaussian_kernel_1d(sigma: f32) -> Vec<f32> {
404    let half = ((sigma * 3.0).ceil() as i32).clamp(1, MAX_GAUSSIAN_KERNEL_HALF);
405    if half <= 0 {
406        return vec![1.0];
407    }
408
409    let mut kernel = Vec::with_capacity((half * 2 + 1) as usize);
410    let mut sum = 0.0f32;
411    for offset in -half..=half {
412        let distance = offset as f32;
413        let weight = (-0.5 * (distance / sigma).powi(2)).exp();
414        kernel.push(weight);
415        sum += weight;
416    }
417
418    if sum > f32::EPSILON {
419        for weight in &mut kernel {
420            *weight /= sum;
421        }
422    }
423
424    kernel
425}
426
427fn outline_glyph_with_bounds(
428    font: &impl Font,
429    glyph: &Glyph,
430) -> Option<(OutlinedGlyph, GlyphPixelBounds)> {
431    let outlined = font.outline_glyph(glyph.clone())?;
432    let bounds = pixel_bounds_from_outlined(&outlined);
433    Some((outlined, bounds))
434}
435
436fn build_glyph_mask(
437    font: &impl Font,
438    glyph: &Glyph,
439    outlined: &OutlinedGlyph,
440    bounds: GlyphPixelBounds,
441    style: GlyphRasterStyle,
442) -> Option<GlyphMask> {
443    match style {
444        GlyphRasterStyle::Fill => build_fill_mask(outlined, bounds),
445        GlyphRasterStyle::Stroke { width_px } => {
446            build_stroke_mask(font, glyph, outlined, bounds, width_px)
447        }
448    }
449}
450
451fn build_fill_mask(outlined: &OutlinedGlyph, bounds: GlyphPixelBounds) -> Option<GlyphMask> {
452    let mask_width = bounds.width();
453    let mask_height = bounds.height();
454    if mask_width == 0 || mask_height == 0 {
455        return None;
456    }
457
458    let mut alpha = vec![0.0f32; mask_width * mask_height];
459    outlined.draw(|gx, gy, value| {
460        let idx = gy as usize * mask_width + gx as usize;
461        alpha[idx] = value;
462    });
463
464    Some(GlyphMask {
465        alpha,
466        width: mask_width,
467        height: mask_height,
468        origin_x: bounds.min_x,
469        origin_y: bounds.min_y,
470    })
471}
472
473fn build_stroke_mask(
474    font: &impl Font,
475    glyph: &Glyph,
476    outlined: &OutlinedGlyph,
477    bounds: GlyphPixelBounds,
478    stroke_width_px: f32,
479) -> Option<GlyphMask> {
480    if !stroke_width_px.is_finite() || stroke_width_px <= 0.0 {
481        return build_fill_mask(outlined, bounds);
482    }
483
484    let mask_width = bounds.max_x - bounds.min_x;
485    let mask_height = bounds.max_y - bounds.min_y;
486    if mask_width <= 0 || mask_height <= 0 {
487        return None;
488    }
489
490    let half_width = stroke_width_px * 0.5;
491    let miter_pad = (half_width * COMPOSE_STROKE_MITER_LIMIT).ceil();
492    let pad = miter_pad.max(1.0) as i32 + 1;
493    let path = build_outline_path(font, glyph, bounds, pad)?;
494    let raster_width = mask_width + pad * 2;
495    let raster_height = mask_height + pad * 2;
496    if raster_width <= 0 || raster_height <= 0 {
497        return None;
498    }
499
500    let mut pixmap = Pixmap::new(raster_width as u32, raster_height as u32)?;
501    let mut paint = Paint::default();
502    paint.set_color_rgba8(255, 255, 255, 255);
503    paint.anti_alias = true;
504
505    let stroke = Stroke {
506        width: stroke_width_px,
507        line_cap: LineCap::Butt,
508        line_join: LineJoin::Miter,
509        miter_limit: COMPOSE_STROKE_MITER_LIMIT,
510        ..Stroke::default()
511    };
512
513    pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
514
515    let alpha = pixmap
516        .data()
517        .chunks_exact(4)
518        .map(|pixel| pixel[3] as f32 / 255.0)
519        .collect();
520
521    Some(GlyphMask {
522        alpha,
523        width: raster_width as usize,
524        height: raster_height as usize,
525        origin_x: bounds.min_x - pad,
526        origin_y: bounds.min_y - pad,
527    })
528}
529
530fn build_outline_path(
531    font: &impl Font,
532    glyph: &Glyph,
533    bounds: GlyphPixelBounds,
534    pad: i32,
535) -> Option<Path> {
536    let outline = font.outline(glyph.id)?;
537    let scale_factor = font.as_scaled(glyph.scale).scale_factor();
538    let mut builder = PathBuilder::new();
539    let mut has_segments = false;
540    let mut current_end = None;
541    let mut subpath_start = None;
542
543    for curve in outline.curves {
544        match curve {
545            ab_glyph::OutlineCurve::Line(p0, p1) => {
546                let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
547                let end = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
548                if current_end != Some(start) {
549                    if current_end.is_some() {
550                        builder.close();
551                    }
552                    builder.move_to(start.0, start.1);
553                    subpath_start = Some(start);
554                }
555                builder.line_to(end.0, end.1);
556                if subpath_start == Some(end) {
557                    builder.close();
558                    current_end = None;
559                    subpath_start = None;
560                } else {
561                    current_end = Some(end);
562                }
563            }
564            ab_glyph::OutlineCurve::Quad(p0, p1, p2) => {
565                let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
566                let control = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
567                let end = transform_outline_point(p2, scale_factor, glyph, bounds, pad);
568                if current_end != Some(start) {
569                    if current_end.is_some() {
570                        builder.close();
571                    }
572                    builder.move_to(start.0, start.1);
573                    subpath_start = Some(start);
574                }
575                builder.quad_to(control.0, control.1, end.0, end.1);
576                if subpath_start == Some(end) {
577                    builder.close();
578                    current_end = None;
579                    subpath_start = None;
580                } else {
581                    current_end = Some(end);
582                }
583            }
584            ab_glyph::OutlineCurve::Cubic(p0, p1, p2, p3) => {
585                let start = transform_outline_point(p0, scale_factor, glyph, bounds, pad);
586                let control1 = transform_outline_point(p1, scale_factor, glyph, bounds, pad);
587                let control2 = transform_outline_point(p2, scale_factor, glyph, bounds, pad);
588                let end = transform_outline_point(p3, scale_factor, glyph, bounds, pad);
589                if current_end != Some(start) {
590                    if current_end.is_some() {
591                        builder.close();
592                    }
593                    builder.move_to(start.0, start.1);
594                    subpath_start = Some(start);
595                }
596                builder.cubic_to(control1.0, control1.1, control2.0, control2.1, end.0, end.1);
597                if subpath_start == Some(end) {
598                    builder.close();
599                    current_end = None;
600                    subpath_start = None;
601                } else {
602                    current_end = Some(end);
603                }
604            }
605        }
606        has_segments = true;
607    }
608
609    if !has_segments {
610        return None;
611    }
612
613    if current_end.is_some() {
614        builder.close();
615    }
616
617    builder.finish()
618}
619
620fn transform_outline_point(
621    point: ab_glyph::Point,
622    scale_factor: ab_glyph::PxScaleFactor,
623    glyph: &Glyph,
624    bounds: GlyphPixelBounds,
625    pad: i32,
626) -> (f32, f32) {
627    (
628        point.x * scale_factor.horizontal + glyph.position.x - bounds.min_x as f32 + pad as f32,
629        point.y * -scale_factor.vertical + glyph.position.y - bounds.min_y as f32 + pad as f32,
630    )
631}
632
633fn color_to_rgba(color: Color) -> [f32; 4] {
634    [
635        color.0.clamp(0.0, 1.0),
636        color.1.clamp(0.0, 1.0),
637        color.2.clamp(0.0, 1.0),
638        color.3.clamp(0.0, 1.0),
639    ]
640}
641
642fn sample_brush(brush: &Brush, rect: Rect, x: f32, y: f32) -> [f32; 4] {
643    match brush {
644        Brush::Solid(color) => color_to_rgba(*color),
645        Brush::LinearGradient {
646            colors,
647            stops,
648            start,
649            end,
650            tile_mode,
651        } => {
652            let sx = resolve_gradient_point(rect.x, rect.width, start.x);
653            let sy = resolve_gradient_point(rect.y, rect.height, start.y);
654            let ex = resolve_gradient_point(rect.x, rect.width, end.x);
655            let ey = resolve_gradient_point(rect.y, rect.height, end.y);
656            let dx = ex - sx;
657            let dy = ey - sy;
658            let denom = (dx * dx + dy * dy).max(f32::EPSILON);
659            let t = ((x - sx) * dx + (y - sy) * dy) / denom;
660            match normalize_gradient_t(t, *tile_mode) {
661                Some(sample_t) => {
662                    color_to_rgba(interpolate_colors(colors, stops.as_deref(), sample_t))
663                }
664                None => [0.0, 0.0, 0.0, 0.0],
665            }
666        }
667        Brush::RadialGradient {
668            colors,
669            stops,
670            center,
671            radius,
672            tile_mode,
673        } => {
674            let cx = rect.x + center.x;
675            let cy = rect.y + center.y;
676            let radius = (*radius).max(f32::EPSILON);
677            let dx = x - cx;
678            let dy = y - cy;
679            let distance = (dx * dx + dy * dy).sqrt();
680            let t = distance / radius;
681            match normalize_gradient_t(t, *tile_mode) {
682                Some(sample_t) => {
683                    color_to_rgba(interpolate_colors(colors, stops.as_deref(), sample_t))
684                }
685                None => [0.0, 0.0, 0.0, 0.0],
686            }
687        }
688        Brush::SweepGradient {
689            colors,
690            stops,
691            center,
692        } => {
693            let cx = rect.x + center.x;
694            let cy = rect.y + center.y;
695            let dx = x - cx;
696            let dy = y - cy;
697            let angle = dy.atan2(dx);
698            let t = (angle / std::f32::consts::TAU + 0.5).clamp(0.0, 1.0);
699            color_to_rgba(interpolate_colors(colors, stops.as_deref(), t))
700        }
701    }
702}
703
704fn resolve_gradient_point(origin: f32, extent: f32, value: f32) -> f32 {
705    if value.is_finite() {
706        origin + value
707    } else if value.is_sign_positive() {
708        origin + extent
709    } else {
710        origin
711    }
712}
713
714fn normalize_gradient_t(t: f32, tile_mode: TileMode) -> Option<f32> {
715    match tile_mode {
716        TileMode::Clamp => Some(t.clamp(0.0, 1.0)),
717        TileMode::Decal => {
718            if (0.0..=1.0).contains(&t) {
719                Some(t)
720            } else {
721                None
722            }
723        }
724        TileMode::Repeated => Some(t.rem_euclid(1.0)),
725        TileMode::Mirror => {
726            let wrapped = t.rem_euclid(2.0);
727            if wrapped <= 1.0 {
728                Some(wrapped)
729            } else {
730                Some(2.0 - wrapped)
731            }
732        }
733    }
734}
735
736fn interpolate_colors(colors: &[Color], stops: Option<&[f32]>, t: f32) -> Color {
737    if colors.is_empty() {
738        return Color(0.0, 0.0, 0.0, 0.0);
739    }
740    if colors.len() == 1 {
741        return colors[0];
742    }
743    let clamped = t.clamp(0.0, 1.0);
744
745    if let Some(stops) = stops {
746        if stops.len() == colors.len() {
747            if clamped <= stops[0] {
748                return colors[0];
749            }
750            for index in 0..(stops.len() - 1) {
751                let start = stops[index];
752                let end = stops[index + 1];
753                if clamped <= end {
754                    let span = (end - start).max(f32::EPSILON);
755                    let frac = ((clamped - start) / span).clamp(0.0, 1.0);
756                    return lerp_color(colors[index], colors[index + 1], frac);
757                }
758            }
759            return *colors.last().unwrap_or(&colors[0]);
760        }
761    }
762
763    let segments = (colors.len() - 1) as f32;
764    let scaled = clamped * segments;
765    let index = scaled.floor() as usize;
766    if index >= colors.len() - 1 {
767        return *colors.last().unwrap();
768    }
769    let frac = scaled - index as f32;
770    lerp_color(colors[index], colors[index + 1], frac)
771}
772
773fn lerp_color(a: Color, b: Color, t: f32) -> Color {
774    let lerp = |start: f32, end: f32| start + (end - start) * t;
775    Color(
776        lerp(a.0, b.0),
777        lerp(a.1, b.1),
778        lerp(a.2, b.2),
779        lerp(a.3, b.3),
780    )
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786    use cranpose_ui::text::SpanStyle;
787    use cranpose_ui_graphics::Point;
788
789    fn count_ink_pixels(image: &ImageBitmap) -> usize {
790        image
791            .pixels()
792            .chunks_exact(4)
793            .filter(|px| px[3] > 0)
794            .count()
795    }
796
797    fn average_ink_rgb(
798        image: &ImageBitmap,
799        x_start: u32,
800        x_end: u32,
801        y_start: u32,
802        y_end: u32,
803    ) -> Option<[f32; 3]> {
804        let width = image.width();
805        let height = image.height();
806        let mut sums = [0.0f32; 3];
807        let mut count = 0usize;
808        let pixels = image.pixels();
809
810        let x_end = x_end.min(width);
811        let y_end = y_end.min(height);
812        for y in y_start.min(height)..y_end {
813            for x in x_start.min(width)..x_end {
814                let idx = ((y * width + x) * 4) as usize;
815                let alpha = pixels[idx + 3];
816                if alpha == 0 {
817                    continue;
818                }
819                sums[0] += pixels[idx] as f32 / 255.0;
820                sums[1] += pixels[idx + 1] as f32 / 255.0;
821                sums[2] += pixels[idx + 2] as f32 / 255.0;
822                count += 1;
823            }
824        }
825
826        if count == 0 {
827            return None;
828        }
829        Some([
830            sums[0] / count as f32,
831            sums[1] / count as f32,
832            sums[2] / count as f32,
833        ])
834    }
835
836    fn ink_x_range(image: &ImageBitmap) -> Option<(u32, u32)> {
837        let width = image.width();
838        let height = image.height();
839        let pixels = image.pixels();
840        let mut min_x = u32::MAX;
841        let mut max_x = 0u32;
842        let mut found = false;
843        for y in 0..height {
844            for x in 0..width {
845                let idx = ((y * width + x) * 4) as usize;
846                if pixels[idx + 3] > 0 {
847                    min_x = min_x.min(x);
848                    max_x = max_x.max(x + 1);
849                    found = true;
850                }
851            }
852        }
853        found.then_some((min_x, max_x))
854    }
855
856    fn top_ink_row(image: &ImageBitmap) -> Option<u32> {
857        let width = image.width();
858        let height = image.height();
859        let pixels = image.pixels();
860        for y in 0..height {
861            for x in 0..width {
862                let idx = ((y * width + x) * 4) as usize;
863                if pixels[idx + 3] > 0 {
864                    return Some(y);
865                }
866            }
867        }
868        None
869    }
870
871    fn reference_dilation_offsets(radius: i32) -> Vec<(i32, i32)> {
872        let mut offsets = Vec::new();
873        let squared_radius = radius * radius;
874        for dy in -radius..=radius {
875            for dx in -radius..=radius {
876                if dx * dx + dy * dy <= squared_radius {
877                    offsets.push((dx, dy));
878                }
879            }
880        }
881        if offsets.is_empty() {
882            offsets.push((0, 0));
883        }
884        offsets
885    }
886
887    fn reference_dilation_stroke_mask(fill: &GlyphMask, stroke_width: f32) -> GlyphMask {
888        let radius = (stroke_width * 0.5).ceil() as i32;
889        let offsets = reference_dilation_offsets(radius);
890        let out_width = fill.width as i32 + radius * 2;
891        let out_height = fill.height as i32 + radius * 2;
892        let fill_width_i32 = fill.width as i32;
893        let fill_height_i32 = fill.height as i32;
894        let mut alpha = vec![0.0f32; (out_width * out_height) as usize];
895
896        for out_y in 0..out_height {
897            let oy = out_y - radius;
898            for out_x in 0..out_width {
899                let ox = out_x - radius;
900                let base_alpha =
901                    if ox >= 0 && oy >= 0 && ox < fill_width_i32 && oy < fill_height_i32 {
902                        fill.alpha[oy as usize * fill.width + ox as usize]
903                    } else {
904                        0.0
905                    };
906
907                let mut dilated_alpha = 0.0f32;
908                for (dx, dy) in &offsets {
909                    let sx = ox + dx;
910                    let sy = oy + dy;
911                    if sx < 0 || sy < 0 || sx >= fill_width_i32 || sy >= fill_height_i32 {
912                        continue;
913                    }
914                    let sample = fill.alpha[sy as usize * fill.width + sx as usize];
915                    if sample > dilated_alpha {
916                        dilated_alpha = sample;
917                        if dilated_alpha >= 0.999 {
918                            break;
919                        }
920                    }
921                }
922                alpha[out_y as usize * out_width as usize + out_x as usize] =
923                    (dilated_alpha - base_alpha).max(0.0);
924            }
925        }
926
927        GlyphMask {
928            alpha,
929            width: out_width as usize,
930            height: out_height as usize,
931            origin_x: fill.origin_x - radius,
932            origin_y: fill.origin_y - radius,
933        }
934    }
935
936    fn rasterize_reference_dilation_stroke(
937        text: &str,
938        rect: Rect,
939        font_size: f32,
940        stroke_width: f32,
941        font: &impl Font,
942    ) -> ImageBitmap {
943        let width = rect.width.ceil().max(1.0) as u32;
944        let height = rect.height.ceil().max(1.0) as u32;
945        let mut canvas = vec![[0.0f32; 4]; (width * height) as usize];
946
947        let baseline = vertical_metrics(font, font_size).ascent;
948        for glyph in layout_line_glyphs(font, text, font_size, point(0.0, baseline)) {
949            let Some((outlined, bounds)) = outline_glyph_with_bounds(font, &glyph) else {
950                continue;
951            };
952            let Some(fill) = build_fill_mask(&outlined, bounds) else {
953                continue;
954            };
955            let reference = reference_dilation_stroke_mask(&fill, stroke_width);
956            draw_mask_glyph(
957                &mut canvas,
958                width,
959                height,
960                &reference,
961                &Brush::solid(Color::WHITE),
962                1.0,
963                rect,
964            );
965        }
966
967        let mut rgba = vec![0u8; canvas.len() * 4];
968        for (index, pixel) in canvas.iter().enumerate() {
969            let base = index * 4;
970            rgba[base] = (pixel[0].clamp(0.0, 1.0) * 255.0).round() as u8;
971            rgba[base + 1] = (pixel[1].clamp(0.0, 1.0) * 255.0).round() as u8;
972            rgba[base + 2] = (pixel[2].clamp(0.0, 1.0) * 255.0).round() as u8;
973            rgba[base + 3] = (pixel[3].clamp(0.0, 1.0) * 255.0).round() as u8;
974        }
975        ImageBitmap::from_rgba8(width, height, rgba).expect("reference dilation image")
976    }
977
978    fn test_font() -> ab_glyph::FontRef<'static> {
979        ab_glyph::FontRef::try_from_slice(include_bytes!(
980            "../../../../apps/desktop-demo/assets/NotoSansMerged.ttf"
981        ))
982        .expect("font")
983    }
984
985    #[test]
986    fn rasterized_gradient_text_shows_color_transition() {
987        let font = test_font();
988        // Use a gradient sized to the rendered text width so left=red, right=blue.
989        // We first do a plain measurement pass to know the text width.
990        let plain_style = TextStyle::default();
991        let probe = rasterize_text_to_image_with_font(
992            "MMMMMMMM",
993            Rect {
994                x: 0.0,
995                y: 0.0,
996                width: 320.0,
997                height: 96.0,
998            },
999            &plain_style,
1000            Color::WHITE,
1001            48.0,
1002            1.0,
1003            &font,
1004        )
1005        .expect("probe image");
1006        let (ink_x_min, ink_x_max) = ink_x_range(&probe).expect("probe must contain ink");
1007        let gradient_end = ink_x_max as f32;
1008
1009        let style = TextStyle {
1010            span_style: SpanStyle {
1011                brush: Some(Brush::linear_gradient_range(
1012                    vec![Color::RED, Color::BLUE],
1013                    Point::new(0.0, 0.0),
1014                    Point::new(gradient_end, 0.0),
1015                )),
1016                ..Default::default()
1017            },
1018            ..Default::default()
1019        };
1020
1021        let image = rasterize_text_to_image_with_font(
1022            "MMMMMMMM",
1023            Rect {
1024                x: 0.0,
1025                y: 0.0,
1026                width: 320.0,
1027                height: 96.0,
1028            },
1029            &style,
1030            Color::WHITE,
1031            48.0,
1032            1.0,
1033            &font,
1034        )
1035        .expect("rasterized image");
1036
1037        let ink_span = ink_x_max.saturating_sub(ink_x_min).max(1);
1038        let left_end = ink_x_min + ink_span * 3 / 10;
1039        let right_start = ink_x_max.saturating_sub(ink_span * 3 / 10);
1040        let left = average_ink_rgb(&image, ink_x_min, left_end, 8, 90).expect("left ink");
1041        let right = average_ink_rgb(&image, right_start, ink_x_max, 8, 90).expect("right ink");
1042        assert!(
1043            left[0] > left[2] * 1.1,
1044            "left region should be red dominant, got {left:?}"
1045        );
1046        assert!(
1047            right[2] > right[0] * 1.1,
1048            "right region should be blue dominant, got {right:?}"
1049        );
1050    }
1051
1052    #[test]
1053    fn rasterized_stroke_and_fill_ink_coverage_differs() {
1054        let font = test_font();
1055        let fill_style = TextStyle::default();
1056        let stroke_style = TextStyle {
1057            span_style: SpanStyle {
1058                draw_style: Some(TextDrawStyle::Stroke { width: 6.0 }),
1059                ..Default::default()
1060            },
1061            ..Default::default()
1062        };
1063        let rect = Rect {
1064            x: 0.0,
1065            y: 0.0,
1066            width: 320.0,
1067            height: 96.0,
1068        };
1069
1070        let fill = rasterize_text_to_image_with_font(
1071            "MMMMMMMM",
1072            rect,
1073            &fill_style,
1074            Color::WHITE,
1075            48.0,
1076            1.0,
1077            &font,
1078        )
1079        .expect("fill image");
1080        let stroke = rasterize_text_to_image_with_font(
1081            "MMMMMMMM",
1082            rect,
1083            &stroke_style,
1084            Color::WHITE,
1085            48.0,
1086            1.0,
1087            &font,
1088        )
1089        .expect("stroke image");
1090
1091        let fill_ink = count_ink_pixels(&fill);
1092        let stroke_ink = count_ink_pixels(&stroke);
1093        assert_ne!(fill.pixels(), stroke.pixels());
1094        assert!(
1095            fill_ink.abs_diff(stroke_ink) > 300,
1096            "fill/stroke ink coverage should differ; fill={fill_ink}, stroke={stroke_ink}"
1097        );
1098    }
1099
1100    #[test]
1101    fn stroke_path_uses_miter_join_for_acute_apexes() {
1102        let font = test_font();
1103        let fill_style = TextStyle::default();
1104        let stroke_width = 12.0;
1105        let stroke_style = TextStyle {
1106            span_style: SpanStyle {
1107                draw_style: Some(TextDrawStyle::Stroke {
1108                    width: stroke_width,
1109                }),
1110                ..Default::default()
1111            },
1112            ..Default::default()
1113        };
1114        let rect = Rect {
1115            x: 0.0,
1116            y: 0.0,
1117            width: 180.0,
1118            height: 140.0,
1119        };
1120
1121        let fill = rasterize_text_to_image_with_font(
1122            "A",
1123            rect,
1124            &fill_style,
1125            Color::WHITE,
1126            110.0,
1127            1.0,
1128            &font,
1129        )
1130        .expect("fill image");
1131        let stroke = rasterize_text_to_image_with_font(
1132            "A",
1133            rect,
1134            &stroke_style,
1135            Color::WHITE,
1136            110.0,
1137            1.0,
1138            &font,
1139        )
1140        .expect("stroke image");
1141
1142        let fill_top = top_ink_row(&fill).expect("fill top row");
1143        let stroke_top = top_ink_row(&stroke).expect("stroke top row");
1144        let reference_dilation =
1145            rasterize_reference_dilation_stroke("A", rect, 110.0, stroke_width, &font);
1146        let reference_top = top_ink_row(&reference_dilation).expect("reference top row");
1147        let extra_extension = fill_top.saturating_sub(stroke_top) as f32;
1148        let half_stroke = stroke_width * 0.5;
1149        assert!(
1150            extra_extension >= half_stroke - 0.25,
1151            "stroke apex should extend by roughly at least half stroke width; fill_top={fill_top}, stroke_top={stroke_top}, half_stroke={half_stroke:.2}"
1152        );
1153        assert!(
1154            stroke.pixels() != reference_dilation.pixels(),
1155            "path stroke should diverge from mask-dilation reference output"
1156        );
1157        assert!(
1158            stroke_top <= reference_top,
1159            "miter stroke should keep acute apex at least as extended as mask-dilation reference; stroke_top={stroke_top}, reference_top={reference_top}"
1160        );
1161    }
1162
1163    #[test]
1164    fn shadow_blur_radius_changes_spread_for_shared_raster_path() {
1165        let font = test_font();
1166        let base_shadow = Shadow {
1167            color: Color(0.0, 0.0, 0.0, 0.9),
1168            offset: Point::new(5.5, 4.25),
1169            blur_radius: 0.0,
1170        };
1171        let hard_shadow_style = TextStyle {
1172            span_style: SpanStyle {
1173                shadow: Some(base_shadow),
1174                ..Default::default()
1175            },
1176            ..Default::default()
1177        };
1178        let blurred_shadow_style = TextStyle {
1179            span_style: SpanStyle {
1180                shadow: Some(Shadow {
1181                    blur_radius: 9.0,
1182                    ..base_shadow
1183                }),
1184                ..Default::default()
1185            },
1186            ..Default::default()
1187        };
1188        let rect = Rect {
1189            x: 0.0,
1190            y: 0.0,
1191            width: 320.0,
1192            height: 120.0,
1193        };
1194
1195        let hard_shadow = rasterize_text_to_image_with_font(
1196            "Shared shadow",
1197            rect,
1198            &hard_shadow_style,
1199            Color::TRANSPARENT,
1200            48.0,
1201            1.0,
1202            &font,
1203        )
1204        .expect("hard shadow image");
1205        let blurred_shadow = rasterize_text_to_image_with_font(
1206            "Shared shadow",
1207            rect,
1208            &blurred_shadow_style,
1209            Color::TRANSPARENT,
1210            48.0,
1211            1.0,
1212            &font,
1213        )
1214        .expect("blurred shadow image");
1215
1216        let hard_ink = count_ink_pixels(&hard_shadow);
1217        let blurred_ink = count_ink_pixels(&blurred_shadow);
1218        assert_ne!(
1219            hard_shadow.pixels(),
1220            blurred_shadow.pixels(),
1221            "blur radius should change rasterized shadow output"
1222        );
1223        assert!(
1224            blurred_ink > hard_ink,
1225            "blurred shadow should spread to more pixels; hard={hard_ink}, blurred={blurred_ink}"
1226        );
1227    }
1228
1229    #[test]
1230    fn text_motion_changes_fractional_shadow_sampling() {
1231        let font = test_font();
1232        let base_shadow = Shadow {
1233            color: Color(0.0, 0.0, 0.0, 0.9),
1234            offset: Point::new(3.35, 2.65),
1235            blur_radius: 6.0,
1236        };
1237        let static_style = TextStyle {
1238            span_style: SpanStyle {
1239                shadow: Some(base_shadow),
1240                ..Default::default()
1241            },
1242            paragraph_style: cranpose_ui::text::ParagraphStyle {
1243                text_motion: Some(TextMotion::Static),
1244                ..Default::default()
1245            },
1246        };
1247        let animated_style = TextStyle {
1248            span_style: SpanStyle {
1249                shadow: Some(base_shadow),
1250                ..Default::default()
1251            },
1252            paragraph_style: cranpose_ui::text::ParagraphStyle {
1253                text_motion: Some(TextMotion::Animated),
1254                ..Default::default()
1255            },
1256        };
1257        let rect = Rect {
1258            x: 11.35,
1259            y: 7.65,
1260            width: 280.0,
1261            height: 120.0,
1262        };
1263
1264        let static_image = rasterize_text_to_image_with_font(
1265            "Motion shadow",
1266            rect,
1267            &static_style,
1268            Color::TRANSPARENT,
1269            42.0,
1270            1.0,
1271            &font,
1272        )
1273        .expect("static image");
1274        let animated_image = rasterize_text_to_image_with_font(
1275            "Motion shadow",
1276            rect,
1277            &animated_style,
1278            Color::TRANSPARENT,
1279            42.0,
1280            1.0,
1281            &font,
1282        )
1283        .expect("animated image");
1284
1285        assert_ne!(
1286            static_image.pixels(),
1287            animated_image.pixels(),
1288            "TextMotion::Static should quantize shadow placement while Animated keeps fractional sampling"
1289        );
1290    }
1291
1292    #[test]
1293    fn static_text_motion_aligns_glyph_positions_to_pixel_grid() {
1294        let font = test_font();
1295        let base_glyph = layout_line_glyphs(&font, "A", 17.0, point(0.0, 13.37))
1296            .into_iter()
1297            .next()
1298            .expect("glyph");
1299        let static_aligned = align_glyph_for_text_motion(base_glyph, true);
1300        let static_position = static_aligned.position;
1301        assert!(
1302            (static_position.x - static_position.x.round()).abs() < f32::EPSILON,
1303            "static text should snap glyph x to pixel grid"
1304        );
1305        assert!(
1306            (static_position.y - static_position.y.round()).abs() < f32::EPSILON,
1307            "static text should snap glyph y to pixel grid"
1308        );
1309
1310        let animated_source = layout_line_glyphs(&font, "A", 17.0, point(0.0, 13.37))
1311            .into_iter()
1312            .next()
1313            .expect("glyph");
1314        let animated_aligned = align_glyph_for_text_motion(animated_source, false);
1315        let animated_position = animated_aligned.position;
1316        assert!(
1317            (animated_position.y - 13.37).abs() < 1e-3,
1318            "animated text should preserve fractional glyph position"
1319        );
1320    }
1321}