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 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}