Skip to main content

cranpose_render_common/
software_text_raster.rs

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