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