1use std::borrow::Borrow;
2use std::hash::{Hash, Hasher};
3use std::rc::Rc;
4use std::sync::{Mutex, MutexGuard};
5
6use cranpose_render_common::bounded_lru_cache::BoundedLruCache;
7use cranpose_render_common::brush_sampling::sample_brush_rgba;
8use cranpose_render_common::graph_scene::RenderDiagnostics;
9use cranpose_render_common::software_text_raster::{
10 cursor_x_for_offset_with_font, default_software_text_font, layout_text_with_font,
11 measure_text_with_font, rasterize_text_to_image, text_offset_for_position_with_font,
12 SoftwareTextFont,
13};
14use cranpose_render_common::text_hyphenation::HyphenationDictionaryStore;
15use cranpose_ui::text::TextMotion;
16use cranpose_ui::text_layout_result::TextLayoutResult;
17use cranpose_ui::{TextMeasurer, TextMetrics};
18use cranpose_ui_graphics::{BlendMode, ColorFilter, Point, Rect};
19
20use crate::pipeline;
21use crate::scene::{ImageDraw, RasterScene, Scene, TextDraw};
22use crate::style::point_in_resolved_rounded_rect;
23
24#[derive(Clone)]
25pub struct PixelsTextResources {
26 font: Option<SoftwareTextFont>,
27}
28
29impl PixelsTextResources {
30 pub fn default_font() -> Self {
31 Self {
32 font: default_software_text_font(),
33 }
34 }
35
36 fn font(&self) -> Option<&SoftwareTextFont> {
37 self.font.as_ref()
38 }
39}
40
41impl Default for PixelsTextResources {
42 fn default() -> Self {
43 Self::default_font()
44 }
45}
46
47fn is_blend_mode_supported(mode: BlendMode) -> bool {
48 matches!(mode, BlendMode::SrcOver | BlendMode::DstOut)
49}
50
51fn fallback_char_width(font_size: f32) -> f32 {
52 font_size.max(1.0) * 0.55
53}
54
55fn fallback_line_height(font_size: f32) -> f32 {
56 font_size.max(1.0) * 1.2
57}
58
59fn fallback_text_metrics(text: &str, font_size: f32) -> TextMetrics {
60 let line_height = fallback_line_height(font_size);
61 let mut line_count = 0usize;
62 let mut max_chars = 0usize;
63 for line in text.split('\n') {
64 line_count += 1;
65 max_chars = max_chars.max(line.chars().count());
66 }
67 let line_count = line_count.max(1);
68 TextMetrics {
69 width: max_chars as f32 * fallback_char_width(font_size),
70 height: line_count as f32 * line_height,
71 line_height,
72 line_count,
73 }
74}
75
76fn fallback_cursor_x_for_byte_offset(text: &str, byte_offset: usize, font_size: f32) -> f32 {
77 let clamped = byte_offset.min(text.len());
78 let char_count = if clamped == text.len() {
79 text.chars().count()
80 } else {
81 text.char_indices()
82 .take_while(|(index, _)| *index < clamped)
83 .count()
84 };
85 char_count as f32 * fallback_char_width(font_size)
86}
87
88fn snap_delta_for_anchor(anchor: Point) -> Point {
89 Point::new(anchor.x.round() - anchor.x, anchor.y.round() - anchor.y)
90}
91
92pub struct CachedFontTextMeasurer {
93 text_resources: PixelsTextResources,
94 cache: Mutex<TextMetricsCache>,
95 hyphenation: HyphenationDictionaryStore,
96}
97
98#[derive(Clone)]
99struct TextKey {
100 text: Rc<str>,
101 font_size_bits: u32,
102 style_hash: u64,
103}
104
105impl PartialEq for TextKey {
106 fn eq(&self, other: &Self) -> bool {
107 (Rc::ptr_eq(&self.text, &other.text) || *self.text == *other.text)
108 && self.font_size_bits == other.font_size_bits
109 && self.style_hash == other.style_hash
110 }
111}
112
113impl Eq for TextKey {}
114
115impl Hash for TextKey {
116 fn hash<H: Hasher>(&self, state: &mut H) {
117 self.text.hash(state);
118 self.font_size_bits.hash(state);
119 self.style_hash.hash(state);
120 }
121}
122
123impl Borrow<str> for TextKey {
124 fn borrow(&self) -> &str {
125 &self.text
126 }
127}
128
129struct TextMetricsCache {
130 map: BoundedLruCache<TextKey, TextMetrics>,
131}
132
133impl TextMetricsCache {
134 fn new(capacity: usize) -> Self {
135 Self {
136 map: BoundedLruCache::with_capacity_at_least_one(capacity),
137 }
138 }
139
140 fn get_or_measure<F>(
141 &mut self,
142 text: &str,
143 font_size: f32,
144 style_hash: u64,
145 measure: F,
146 ) -> TextMetrics
147 where
148 F: FnOnce(&str, f32) -> TextMetrics,
149 {
150 let key = TextKey {
153 text: Rc::from(text),
154 font_size_bits: font_size.to_bits(),
155 style_hash,
156 };
157
158 if let Some(metrics) = self.map.get(&key).copied() {
159 return metrics;
160 }
161
162 let metrics = measure(text, font_size);
163 self.map.put(key, metrics);
164 metrics
165 }
166}
167
168impl CachedFontTextMeasurer {
169 pub(crate) fn with_text_resources(
170 text_resources: PixelsTextResources,
171 capacity: usize,
172 ) -> Self {
173 Self {
174 text_resources,
175 cache: Mutex::new(TextMetricsCache::new(capacity)),
176 hyphenation: HyphenationDictionaryStore::new(),
177 }
178 }
179
180 fn lock_cache(&self) -> MutexGuard<'_, TextMetricsCache> {
181 self.cache
182 .lock()
183 .unwrap_or_else(|poisoned| poisoned.into_inner())
184 }
185}
186
187#[derive(Clone, Copy)]
188struct ClipBounds {
189 min_x: i32,
190 min_y: i32,
191 max_x: i32,
192 max_y: i32,
193}
194
195fn clip_rect_to_bounds(
196 rect: Rect,
197 clip: Option<Rect>,
198 width: u32,
199 height: u32,
200) -> Option<ClipBounds> {
201 let mut min_x = rect.x;
202 let mut min_y = rect.y;
203 let mut max_x = rect.x + rect.width;
204 let mut max_y = rect.y + rect.height;
205
206 if let Some(clip_rect) = clip {
207 min_x = min_x.max(clip_rect.x);
208 min_y = min_y.max(clip_rect.y);
209 max_x = max_x.min(clip_rect.x + clip_rect.width);
210 max_y = max_y.min(clip_rect.y + clip_rect.height);
211 }
212
213 min_x = min_x.max(0.0);
214 min_y = min_y.max(0.0);
215 max_x = max_x.min(width as f32);
216 max_y = max_y.min(height as f32);
217
218 if max_x <= min_x || max_y <= min_y {
219 return None;
220 }
221
222 let min_x = min_x.floor() as i32;
223 let min_y = min_y.floor() as i32;
224 let max_x = max_x.ceil() as i32;
225 let max_y = max_y.ceil() as i32;
226
227 let min_x = min_x.clamp(0, width as i32);
228 let min_y = min_y.clamp(0, height as i32);
229 let max_x = max_x.clamp(0, width as i32);
230 let max_y = max_y.clamp(0, height as i32);
231
232 if min_x >= max_x || min_y >= max_y {
233 return None;
234 }
235
236 Some(ClipBounds {
237 min_x,
238 min_y,
239 max_x,
240 max_y,
241 })
242}
243
244fn resolve_font_size(style: &cranpose_ui::text::TextStyle) -> f32 {
246 style.resolve_font_size(14.0)
247}
248
249impl TextMeasurer for CachedFontTextMeasurer {
250 fn measure(
251 &self,
252 text: &cranpose_ui::text::AnnotatedString,
253 style: &cranpose_ui::text::TextStyle,
254 ) -> TextMetrics {
255 let text_str = text.text.as_str();
256 let font_size = resolve_font_size(style);
257 let style_hash = style.measurement_hash();
258 self.lock_cache()
259 .get_or_measure(text_str, font_size, style_hash, |value, size| {
260 measure_text_impl(value, style, size, self.text_resources.font())
261 })
262 }
263
264 fn get_offset_for_position(
265 &self,
266 text: &cranpose_ui::text::AnnotatedString,
267 style: &cranpose_ui::text::TextStyle,
268 x: f32,
269 _y: f32,
270 ) -> usize {
271 let text = text.text.as_str();
272 if text.is_empty() {
273 return 0;
274 }
275
276 let Some(font) = self.text_resources.font() else {
277 let font_size = resolve_font_size(style);
278 return TextLayoutResult::monospaced(
279 text,
280 fallback_char_width(font_size),
281 fallback_line_height(font_size),
282 )
283 .get_offset_for_x(x);
284 };
285
286 text_offset_for_position_with_font(text, style, x, _y, font)
287 }
288
289 fn get_cursor_x_for_offset(
290 &self,
291 text: &cranpose_ui::text::AnnotatedString,
292 style: &cranpose_ui::text::TextStyle,
293 offset: usize,
294 ) -> f32 {
295 let text = text.text.as_str();
296 let clamped_offset = offset.min(text.len());
297 if clamped_offset == 0 {
298 return 0.0;
299 }
300
301 let Some(font) = self.text_resources.font() else {
302 return fallback_cursor_x_for_byte_offset(
303 text,
304 clamped_offset,
305 resolve_font_size(style),
306 );
307 };
308
309 cursor_x_for_offset_with_font(text, style, clamped_offset, font)
310 }
311
312 fn layout(
313 &self,
314 text: &cranpose_ui::text::AnnotatedString,
315 style: &cranpose_ui::text::TextStyle,
316 ) -> cranpose_ui::text_layout_result::TextLayoutResult {
317 let font_size = resolve_font_size(style);
318 let Some(font) = self.text_resources.font() else {
319 return TextLayoutResult::monospaced(
320 text.text.as_str(),
321 fallback_char_width(font_size),
322 fallback_line_height(font_size),
323 );
324 };
325
326 layout_text_with_font(text.text.as_str(), style, font)
327 }
328
329 fn choose_auto_hyphen_break(
330 &self,
331 line: &str,
332 style: &cranpose_ui::text::TextStyle,
333 segment_start_char: usize,
334 measured_break_char: usize,
335 ) -> Option<usize> {
336 self.hyphenation.choose_auto_hyphen_break(
337 line,
338 style,
339 segment_start_char,
340 measured_break_char,
341 )
342 }
343}
344
345fn measure_text_impl(
346 text: &str,
347 style: &cranpose_ui::text::TextStyle,
348 font_size: f32,
349 font: Option<&SoftwareTextFont>,
350) -> TextMetrics {
351 let Some(font) = font else {
352 return fallback_text_metrics(text, font_size);
353 };
354
355 measure_text_with_font(text, style, font_size, font)
356}
357
358pub fn draw_scene(frame: &mut [u8], width: u32, height: u32, scene: &Scene) {
359 let text_resources = PixelsTextResources::default();
360 draw_scene_with_text_resources(frame, width, height, scene, &text_resources);
361}
362
363pub fn draw_scene_with_text_resources(
364 frame: &mut [u8],
365 width: u32,
366 height: u32,
367 scene: &Scene,
368 text_resources: &PixelsTextResources,
369) {
370 if let Some(graph) = scene.graph.as_ref() {
371 let raster_scene = pipeline::build_raster_scene(graph, scene.diagnostics());
372 draw_raster_scene(
373 frame,
374 width,
375 height,
376 &raster_scene,
377 scene.diagnostics(),
378 text_resources,
379 );
380 } else {
381 clear_frame(frame);
382 }
383}
384
385fn clear_frame(frame: &mut [u8]) {
386 for chunk in frame.chunks_exact_mut(4) {
387 chunk.copy_from_slice(&[18, 18, 24, 255]);
388 }
389}
390
391fn draw_raster_scene(
392 frame: &mut [u8],
393 width: u32,
394 height: u32,
395 scene: &RasterScene,
396 diagnostics: &RenderDiagnostics,
397 text_resources: &PixelsTextResources,
398) {
399 clear_frame(frame);
400 let mut ordered_items =
401 Vec::with_capacity(scene.shapes.len() + scene.images.len() + scene.texts.len());
402 for (index, shape) in scene.shapes.iter().enumerate() {
403 ordered_items.push((shape.z_index, RenderItem::Shape(index)));
404 }
405 for (index, image) in scene.images.iter().enumerate() {
406 ordered_items.push((image.z_index, RenderItem::Image(index)));
407 }
408 for (index, text) in scene.texts.iter().enumerate() {
409 ordered_items.push((text.z_index, RenderItem::Text(index)));
410 }
411 ordered_items.sort_by_key(|(z, _)| *z);
412
413 for (_, item) in ordered_items {
414 match item {
415 RenderItem::Shape(index) => {
416 draw_shape(frame, width, height, &scene.shapes[index], diagnostics);
417 }
418 RenderItem::Image(index) => {
419 draw_image(frame, width, height, &scene.images[index], diagnostics);
420 }
421 RenderItem::Text(index) => {
422 draw_text(
423 frame,
424 width,
425 height,
426 &scene.texts[index],
427 diagnostics,
428 text_resources,
429 );
430 }
431 }
432 }
433}
434
435#[derive(Clone, Copy, Debug, PartialEq, Eq)]
436enum RenderItem {
437 Shape(usize),
438 Image(usize),
439 Text(usize),
440}
441
442fn draw_shape(
443 frame: &mut [u8],
444 width: u32,
445 height: u32,
446 draw: &crate::scene::DrawShape,
447 diagnostics: &RenderDiagnostics,
448) {
449 let snap_delta = draw
450 .snap_anchor
451 .map(snap_delta_for_anchor)
452 .unwrap_or_default();
453 let rect = draw.rect.translate(snap_delta.x, snap_delta.y);
454 let clip = draw
455 .clip
456 .map(|clip| clip.translate(snap_delta.x, snap_delta.y));
457 let rect = if draw.snap_to_pixel_grid {
458 Rect {
459 x: rect.x.round(),
460 y: rect.y.round(),
461 width: if rect.width > 0.0 {
462 rect.width.ceil().max(1.0)
463 } else {
464 rect.width
465 },
466 height: if rect.height > 0.0 {
467 rect.height.ceil().max(1.0)
468 } else {
469 rect.height
470 },
471 }
472 } else {
473 rect
474 };
475 let clip_bounds = match clip_rect_to_bounds(rect, clip, width, height) {
476 Some(bounds) => bounds,
477 None => return,
478 };
479 let Rect {
480 width: rect_width,
481 height: rect_height,
482 ..
483 } = rect;
484 let resolved_shape = draw
485 .shape
486 .map(|shape| shape.resolve(rect_width, rect_height));
487 for py in clip_bounds.min_y..clip_bounds.max_y {
488 if py < 0 || py >= height as i32 {
489 continue;
490 }
491 for px in clip_bounds.min_x..clip_bounds.max_x {
492 if px < 0 || px >= width as i32 {
493 continue;
494 }
495 let center_x = px as f32 + 0.5;
496 let center_y = py as f32 + 0.5;
497 if let Some(ref radii) = resolved_shape {
498 if !point_in_resolved_rounded_rect(center_x, center_y, rect, radii) {
499 continue;
500 }
501 }
502 let sample = sample_brush_rgba(&draw.brush, rect, center_x, center_y);
503 let alpha = sample[3];
504 if alpha <= 0.0 {
505 continue;
506 }
507 let idx = ((py as u32 * width + px as u32) * 4) as usize;
508 blend_pixel(
509 &mut frame[idx..idx + 4],
510 sample,
511 draw.blend_mode,
512 diagnostics,
513 );
514 }
515 }
516}
517
518fn draw_image(
519 frame: &mut [u8],
520 width: u32,
521 height: u32,
522 draw: &ImageDraw,
523 diagnostics: &RenderDiagnostics,
524) {
525 let snap_delta = draw
526 .snap_anchor
527 .map(snap_delta_for_anchor)
528 .unwrap_or_default();
529 let rect = draw.rect.translate(snap_delta.x, snap_delta.y);
530 let clip = draw
531 .clip
532 .map(|clip| clip.translate(snap_delta.x, snap_delta.y));
533
534 if draw.alpha <= 0.0 || rect.width <= 0.0 || rect.height <= 0.0 {
535 return;
536 }
537
538 let clip_bounds = match clip_rect_to_bounds(rect, clip, width, height) {
539 Some(bounds) => bounds,
540 None => return,
541 };
542
543 let img_width = draw.image.width();
544 let img_height = draw.image.height();
545 if img_width == 0 || img_height == 0 {
546 return;
547 }
548 let src_pixels = draw.image.pixels();
549
550 let (sr_x, sr_y, sr_w, sr_h) = if let Some(sr) = draw.src_rect {
552 (sr.x, sr.y, sr.width, sr.height)
553 } else {
554 (0.0, 0.0, img_width as f32, img_height as f32)
555 };
556
557 for py in clip_bounds.min_y..clip_bounds.max_y {
558 for px in clip_bounds.min_x..clip_bounds.max_x {
559 let sample_x = px as f32 + 0.5;
560 let sample_y = py as f32 + 0.5;
561 let u = ((sample_x - rect.x) / rect.width).clamp(0.0, 1.0);
562 let v = ((sample_y - rect.y) / rect.height).clamp(0.0, 1.0);
563
564 let mut sample = match draw.sampling {
565 cranpose_ui_graphics::ImageSampling::Nearest => {
566 let src_x = ((sr_x + u * sr_w).floor() as i32).clamp(0, img_width as i32 - 1);
567 let src_y = ((sr_y + v * sr_h).floor() as i32).clamp(0, img_height as i32 - 1);
568 sample_image_nearest(src_pixels, img_width, src_x as u32, src_y as u32)
569 }
570 cranpose_ui_graphics::ImageSampling::Linear => sample_image_linear(
571 src_pixels,
572 img_width,
573 img_height,
574 sr_x + u * sr_w - 0.5,
575 sr_y + v * sr_h - 0.5,
576 ),
577 };
578
579 if let Some(filter) = draw.color_filter {
580 sample = apply_color_filter(sample, filter);
581 }
582
583 sample[3] *= draw.alpha.clamp(0.0, 1.0);
584 if sample[3] <= 0.0 {
585 continue;
586 }
587
588 let dst_idx = ((py as u32 * width + px as u32) * 4) as usize;
589 blend_pixel(
590 &mut frame[dst_idx..dst_idx + 4],
591 sample,
592 draw.blend_mode,
593 diagnostics,
594 );
595 }
596 }
597}
598
599fn sample_image_nearest(src_pixels: &[u8], img_width: u32, src_x: u32, src_y: u32) -> [f32; 4] {
600 let src_idx = ((src_y * img_width + src_x) * 4) as usize;
601 [
602 src_pixels[src_idx] as f32 / 255.0,
603 src_pixels[src_idx + 1] as f32 / 255.0,
604 src_pixels[src_idx + 2] as f32 / 255.0,
605 src_pixels[src_idx + 3] as f32 / 255.0,
606 ]
607}
608
609fn sample_image_linear(
610 src_pixels: &[u8],
611 img_width: u32,
612 img_height: u32,
613 x: f32,
614 y: f32,
615) -> [f32; 4] {
616 let x = x.clamp(0.0, img_width.saturating_sub(1) as f32);
617 let y = y.clamp(0.0, img_height.saturating_sub(1) as f32);
618 let x0 = x.floor();
619 let y0 = y.floor();
620 let tx = x - x0;
621 let ty = y - y0;
622 let x0 = (x0 as i32).clamp(0, img_width as i32 - 1) as u32;
623 let y0 = (y0 as i32).clamp(0, img_height as i32 - 1) as u32;
624 let x1 = (x0 + 1).min(img_width - 1);
625 let y1 = (y0 + 1).min(img_height - 1);
626 let top_left = sample_image_nearest(src_pixels, img_width, x0, y0);
627 let top_right = sample_image_nearest(src_pixels, img_width, x1, y0);
628 let bottom_left = sample_image_nearest(src_pixels, img_width, x0, y1);
629 let bottom_right = sample_image_nearest(src_pixels, img_width, x1, y1);
630
631 let mut out = [0.0; 4];
632 for channel in 0..4 {
633 let top = top_left[channel] + (top_right[channel] - top_left[channel]) * tx;
634 let bottom = bottom_left[channel] + (bottom_right[channel] - bottom_left[channel]) * tx;
635 out[channel] = top + (bottom - top) * ty;
636 }
637 out
638}
639
640fn draw_text(
641 frame: &mut [u8],
642 width: u32,
643 height: u32,
644 draw: &TextDraw,
645 diagnostics: &RenderDiagnostics,
646 text_resources: &PixelsTextResources,
647) {
648 if draw.text.span_styles.is_empty() {
649 draw_text_plain(frame, width, height, draw, diagnostics, text_resources);
650 return;
651 }
652
653 draw_text_with_span_styles(frame, width, height, draw, diagnostics, text_resources);
654}
655
656fn draw_text_with_span_styles(
657 frame: &mut [u8],
658 width: u32,
659 height: u32,
660 draw: &TextDraw,
661 diagnostics: &RenderDiagnostics,
662 text_resources: &PixelsTextResources,
663) {
664 let boundaries = draw.text.span_boundaries();
665 let mut cursor_x = draw.rect.x;
666 let mut cursor_y = draw.rect.y;
667 let base_line_height = draw
668 .text_style
669 .resolve_line_height(14.0, draw.font_size)
670 .max(1.0);
671 let mut current_line_height = base_line_height;
672
673 for window in boundaries.windows(2) {
674 let start = window[0];
675 let end = window[1];
676 if start == end {
677 continue;
678 }
679
680 let chunk = &draw.text.text[start..end];
681 let mut merged_span = draw.text_style.span_style.clone();
682 for span in &draw.text.span_styles {
683 if span.range.start <= start && span.range.end >= end {
684 merged_span = merged_span.merge(&span.item);
685 }
686 }
687
688 let mut chunk_style = draw.text_style.clone();
689 chunk_style.span_style = merged_span;
690
691 for part in chunk.split_inclusive('\n') {
692 let has_newline = part.ends_with('\n');
693 let content = if has_newline {
694 &part[..part.len().saturating_sub(1)]
695 } else {
696 part
697 };
698
699 if !content.is_empty() {
700 let segment = cranpose_ui::text::AnnotatedString::from(content);
701 let metrics = cranpose_ui::text::measure_text(&segment, &chunk_style);
702 let segment_draw = TextDraw {
703 node_id: draw.node_id,
704 rect: Rect {
705 x: cursor_x,
706 y: cursor_y,
707 width: metrics.width.max(1.0),
708 height: metrics.height.max(1.0),
709 },
710 snap_anchor: draw.snap_anchor,
711 text: Rc::new(segment),
712 color: chunk_style.resolve_text_color(draw.color),
713 text_style: chunk_style.clone(),
714 font_size: chunk_style.resolve_font_size(draw.font_size),
715 scale: draw.scale,
716 layout_options: draw.layout_options,
717 z_index: draw.z_index,
718 clip: draw.clip,
719 };
720 draw_text_plain(
721 frame,
722 width,
723 height,
724 &segment_draw,
725 diagnostics,
726 text_resources,
727 );
728 cursor_x += metrics.width;
729 current_line_height = current_line_height.max(metrics.line_height.max(1.0));
730 }
731
732 if has_newline {
733 cursor_x = draw.rect.x;
734 cursor_y += current_line_height;
735 current_line_height = base_line_height;
736 }
737 }
738 }
739}
740
741fn draw_text_plain(
742 frame: &mut [u8],
743 width: u32,
744 height: u32,
745 draw: &TextDraw,
746 diagnostics: &RenderDiagnostics,
747 text_resources: &PixelsTextResources,
748) {
749 let text_scale = draw.scale.max(0.0);
750 if text_scale == 0.0 {
751 return;
752 }
753
754 let static_text_motion = draw
755 .text_style
756 .paragraph_style
757 .text_motion
758 .unwrap_or(TextMotion::Static)
759 == TextMotion::Static;
760 let snap_delta = if static_text_motion {
761 draw.snap_anchor
762 .map(snap_delta_for_anchor)
763 .unwrap_or_default()
764 } else {
765 Point::default()
766 };
767 let rect = draw.rect.translate(snap_delta.x, snap_delta.y);
768 let clip = draw
769 .clip
770 .map(|clip| clip.translate(snap_delta.x, snap_delta.y));
771
772 let raster_rect = if static_text_motion {
773 Rect {
774 x: rect.x.round(),
775 y: rect.y.round(),
776 width: if rect.width > 0.0 {
777 rect.width.ceil().max(1.0)
778 } else {
779 rect.width
780 },
781 height: if rect.height > 0.0 {
782 rect.height.ceil().max(1.0)
783 } else {
784 rect.height
785 },
786 }
787 } else {
788 rect
789 };
790
791 let Some(font) = text_resources.font() else {
792 return;
793 };
794
795 let Some(image) = rasterize_text_to_image(
796 draw.text.text.as_str(),
797 raster_rect,
798 &draw.text_style,
799 draw.color,
800 draw.font_size,
801 text_scale,
802 font,
803 ) else {
804 return;
805 };
806
807 let blit_origin = if static_text_motion {
808 Point::new(raster_rect.x, raster_rect.y)
809 } else {
810 Point::new(rect.x, rect.y)
811 };
812 let blit_rect = Rect {
813 x: blit_origin.x,
814 y: blit_origin.y,
815 width: image.width() as f32,
816 height: image.height() as f32,
817 };
818
819 blit_rasterized_text_image(frame, width, height, blit_rect, clip, &image, diagnostics);
820}
821
822fn blit_rasterized_text_image(
823 frame: &mut [u8],
824 width: u32,
825 height: u32,
826 rect: Rect,
827 clip: Option<Rect>,
828 image: &cranpose_ui_graphics::ImageBitmap,
829 diagnostics: &RenderDiagnostics,
830) {
831 if rect.width <= 0.0 || rect.height <= 0.0 {
832 return;
833 }
834 let clip_bounds = match clip_rect_to_bounds(rect, clip, width, height) {
835 Some(bounds) => bounds,
836 None => return,
837 };
838
839 let img_width = image.width();
840 let img_height = image.height();
841 if img_width == 0 || img_height == 0 {
842 return;
843 }
844 let src_pixels = image.pixels();
845
846 for py in clip_bounds.min_y..clip_bounds.max_y {
847 for px in clip_bounds.min_x..clip_bounds.max_x {
848 let sample_x = px as f32 + 0.5;
849 let sample_y = py as f32 + 0.5;
850 let u = ((sample_x - rect.x) / rect.width).clamp(0.0, 1.0);
851 let v = ((sample_y - rect.y) / rect.height).clamp(0.0, 1.0);
852
853 let src = sample_image_linear(
854 src_pixels,
855 img_width,
856 img_height,
857 u * img_width.saturating_sub(1) as f32,
858 v * img_height.saturating_sub(1) as f32,
859 );
860 if src[3] <= 0.0 {
861 continue;
862 }
863
864 let dst_idx = ((py as u32 * width + px as u32) * 4) as usize;
865 blend_pixel(
866 &mut frame[dst_idx..dst_idx + 4],
867 src,
868 BlendMode::SrcOver,
869 diagnostics,
870 );
871 }
872 }
873}
874
875fn blend_pixel(
876 dst: &mut [u8],
877 src: [f32; 4],
878 blend_mode: BlendMode,
879 diagnostics: &RenderDiagnostics,
880) {
881 let resolved_blend_mode = if is_blend_mode_supported(blend_mode) {
882 blend_mode
883 } else {
884 if diagnostics.claim_warning_once("pixels.unsupported-blend-mode") {
885 log::warn!(
886 "Pixels renderer currently supports BlendMode::SrcOver and BlendMode::DstOut; falling back to SrcOver for unsupported modes"
887 );
888 }
889 BlendMode::SrcOver
890 };
891
892 let src_alpha = src[3].clamp(0.0, 1.0);
893 if src_alpha <= 0.0 {
894 return;
895 }
896 let dst_r = dst[0] as f32 / 255.0;
897 let dst_g = dst[1] as f32 / 255.0;
898 let dst_b = dst[2] as f32 / 255.0;
899 let dst_a = dst[3] as f32 / 255.0;
900
901 let (out_r, out_g, out_b, out_a) = match resolved_blend_mode {
902 BlendMode::DstOut => {
903 let keep = 1.0 - src_alpha;
904 (dst_r * keep, dst_g * keep, dst_b * keep, dst_a * keep)
905 }
906 BlendMode::SrcOver => (
907 src[0].clamp(0.0, 1.0) * src_alpha + dst_r * (1.0 - src_alpha),
908 src[1].clamp(0.0, 1.0) * src_alpha + dst_g * (1.0 - src_alpha),
909 src[2].clamp(0.0, 1.0) * src_alpha + dst_b * (1.0 - src_alpha),
910 src_alpha + dst_a * (1.0 - src_alpha),
911 ),
912 _ => (
913 src[0].clamp(0.0, 1.0) * src_alpha + dst_r * (1.0 - src_alpha),
914 src[1].clamp(0.0, 1.0) * src_alpha + dst_g * (1.0 - src_alpha),
915 src[2].clamp(0.0, 1.0) * src_alpha + dst_b * (1.0 - src_alpha),
916 src_alpha + dst_a * (1.0 - src_alpha),
917 ),
918 };
919
920 dst[0] = (out_r.clamp(0.0, 1.0) * 255.0).round() as u8;
921 dst[1] = (out_g.clamp(0.0, 1.0) * 255.0).round() as u8;
922 dst[2] = (out_b.clamp(0.0, 1.0) * 255.0).round() as u8;
923 dst[3] = (out_a.clamp(0.0, 1.0) * 255.0).round() as u8;
924}
925
926fn apply_color_filter(sample: [f32; 4], filter: ColorFilter) -> [f32; 4] {
927 filter.apply_rgba(sample)
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933 use cranpose_render_common::brush_sampling::normalize_gradient_t;
934 use cranpose_render_common::graph::{
935 CachePolicy, DrawPrimitiveNode, IsolationReasons, LayerNode, PrimitiveEntry, PrimitiveNode,
936 PrimitivePhase, ProjectiveTransform, RenderGraph, RenderNode,
937 };
938 use cranpose_render_common::raster_cache::LayerRasterCacheHashes;
939 use cranpose_ui::Brush;
940 use cranpose_ui_graphics::{Color, TileMode};
941
942 fn draw_raster_scene_for_test(frame: &mut [u8], width: u32, height: u32, scene: &RasterScene) {
943 let diagnostics = RenderDiagnostics::new();
944 let text_resources = PixelsTextResources::default();
945 draw_raster_scene(frame, width, height, scene, &diagnostics, &text_resources);
946 }
947
948 #[test]
949 fn fallback_text_metrics_cover_empty_and_multiline_text() {
950 let empty = fallback_text_metrics("", 10.0);
951 assert_eq!(empty.line_count, 1);
952 assert_eq!(empty.width, 0.0);
953 assert_eq!(empty.height, fallback_line_height(10.0));
954
955 let multiline = fallback_text_metrics("ab\ncde", 10.0);
956 assert_eq!(multiline.line_count, 2);
957 assert_eq!(multiline.width, 3.0 * fallback_char_width(10.0));
958 assert_eq!(multiline.height, 2.0 * fallback_line_height(10.0));
959 }
960
961 #[test]
962 fn fallback_cursor_position_handles_non_boundary_byte_offsets() {
963 let text = "éx";
964 let width = fallback_char_width(12.0);
965 assert_eq!(fallback_cursor_x_for_byte_offset(text, 0, 12.0), 0.0);
966 assert_eq!(fallback_cursor_x_for_byte_offset(text, 1, 12.0), width);
967 assert_eq!(
968 fallback_cursor_x_for_byte_offset(text, text.len(), 12.0),
969 width * 2.0
970 );
971 }
972
973 fn count_non_background_pixels(frame: &[u8], width: u32, height: u32) -> usize {
974 count_non_background_pixels_in_band(frame, width, 0, height)
975 }
976
977 fn render_single_text_frame(
978 style: cranpose_ui::TextStyle,
979 color: Color,
980 x: f32,
981 ) -> (u32, u32, Vec<u8>) {
982 let mut raster_scene = RasterScene::new();
983 raster_scene.push_text(
984 11,
985 Rect {
986 x,
987 y: 16.0,
988 width: 320.0,
989 height: 90.0,
990 },
991 Rc::new(cranpose_ui::text::AnnotatedString::from("MMMMMMMM")),
992 color,
993 style,
994 64.0,
995 1.0,
996 cranpose_ui::TextLayoutOptions::default(),
997 None,
998 );
999
1000 let width = 360;
1001 let height = 140;
1002 let mut frame = vec![0u8; (width * height * 4) as usize];
1003 draw_raster_scene_for_test(&mut frame, width, height, &raster_scene);
1004 (width, height, frame)
1005 }
1006
1007 fn average_ink_rgb(
1008 frame: &[u8],
1009 width: u32,
1010 x_min: u32,
1011 x_max: u32,
1012 y_min: u32,
1013 y_max: u32,
1014 ) -> Option<[f32; 3]> {
1015 let mut sum_r = 0.0f32;
1016 let mut sum_g = 0.0f32;
1017 let mut sum_b = 0.0f32;
1018 let mut count = 0usize;
1019
1020 for y in y_min..y_max {
1021 for x in x_min..x_max {
1022 let idx = ((y * width + x) * 4) as usize;
1023 let px = &frame[idx..idx + 4];
1024 if px == [18, 18, 24, 255] {
1025 continue;
1026 }
1027 sum_r += px[0] as f32 / 255.0;
1028 sum_g += px[1] as f32 / 255.0;
1029 sum_b += px[2] as f32 / 255.0;
1030 count += 1;
1031 }
1032 }
1033
1034 if count == 0 {
1035 return None;
1036 }
1037 Some([
1038 sum_r / count as f32,
1039 sum_g / count as f32,
1040 sum_b / count as f32,
1041 ])
1042 }
1043
1044 fn count_non_background_pixels_in_band(
1045 frame: &[u8],
1046 width: u32,
1047 y_min_inclusive: u32,
1048 y_max_exclusive: u32,
1049 ) -> usize {
1050 let mut count = 0usize;
1051 for y in y_min_inclusive..y_max_exclusive {
1052 for x in 0..width {
1053 let idx = ((y * width + x) * 4) as usize;
1054 let px = &frame[idx..idx + 4];
1055 if px != [18, 18, 24, 255] {
1056 count += 1;
1057 }
1058 }
1059 }
1060 count
1061 }
1062
1063 fn ink_y_range(frame: &[u8], width: u32, height: u32) -> Option<(u32, u32)> {
1065 let mut top = None;
1066 let mut bottom = 0u32;
1067 for y in 0..height {
1068 for x in 0..width {
1069 let idx = ((y * width + x) * 4) as usize;
1070 if frame[idx..idx + 4] != [18, 18, 24, 255] {
1071 top.get_or_insert(y);
1072 bottom = y + 1;
1073 break;
1074 }
1075 }
1076 }
1077 top.map(|t| (t, bottom))
1078 }
1079
1080 #[test]
1081 fn blend_mode_support_matrix_is_explicit() {
1082 assert!(is_blend_mode_supported(BlendMode::SrcOver));
1083 assert!(is_blend_mode_supported(BlendMode::DstOut));
1084 assert!(!is_blend_mode_supported(BlendMode::Clear));
1085 assert!(!is_blend_mode_supported(BlendMode::Multiply));
1086 }
1087
1088 #[test]
1089 fn unsupported_blend_mode_falls_back_without_abort() {
1090 let diagnostics = RenderDiagnostics::new();
1091 let src = [1.0, 0.0, 0.0, 0.5];
1092 let mut unsupported = [0, 0, 255, 255];
1093 let mut src_over = unsupported;
1094
1095 blend_pixel(&mut unsupported, src, BlendMode::Multiply, &diagnostics);
1096 blend_pixel(&mut src_over, src, BlendMode::SrcOver, &diagnostics);
1097
1098 assert_eq!(unsupported, src_over);
1099 }
1100
1101 #[test]
1102 fn cached_font_text_metrics_cache_recovers_after_poison() {
1103 let measurer =
1104 CachedFontTextMeasurer::with_text_resources(PixelsTextResources::default(), 8);
1105 let text = cranpose_ui::text::AnnotatedString::from("Recovered pixels text");
1106
1107 let poison_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1108 let _guard = measurer
1109 .cache
1110 .lock()
1111 .unwrap_or_else(|poisoned| poisoned.into_inner());
1112 panic!("poison pixels text metrics cache for recovery test");
1113 }));
1114
1115 assert!(poison_result.is_err());
1116
1117 let metrics = measurer.measure(&text, &cranpose_ui::text::TextStyle::default());
1118 assert!(metrics.width > 0.0);
1119 assert!(metrics.height > 0.0);
1120 }
1121
1122 #[test]
1123 fn mirror_tile_mode_reflects_second_interval() {
1124 assert_eq!(normalize_gradient_t(1.25, TileMode::Mirror), Some(0.75));
1125 assert_eq!(normalize_gradient_t(1.75, TileMode::Mirror), Some(0.25));
1126 }
1127
1128 #[test]
1129 fn multiline_text_renders_second_line_pixels() {
1130 let mut raster_scene = RasterScene::new();
1131 raster_scene.push_text(
1132 1,
1133 Rect {
1134 x: 8.0,
1135 y: 8.0,
1136 width: 180.0,
1137 height: 80.0,
1138 },
1139 Rc::new(cranpose_ui::text::AnnotatedString::from(
1140 "Dynamic\nModifiers",
1141 )),
1142 Color::WHITE,
1143 cranpose_ui::TextStyle::default(),
1144 14.0,
1145 1.0,
1146 cranpose_ui::TextLayoutOptions::default(),
1147 None,
1148 );
1149
1150 let width = 220;
1151 let height = 100;
1152 let mut frame = vec![0u8; (width * height * 4) as usize];
1153 draw_raster_scene_for_test(&mut frame, width, height, &raster_scene);
1154
1155 let (ink_top, ink_bottom) =
1157 ink_y_range(&frame, width, height).expect("expected ink pixels in rendered text");
1158 let ink_height = ink_bottom - ink_top;
1159 assert!(
1160 ink_height >= 20,
1161 "expected two lines of ink, ink spans only {ink_height}px (y={ink_top}..{ink_bottom})"
1162 );
1163 let mid_y = ink_top + ink_height / 2;
1164 let first_line_ink = count_non_background_pixels_in_band(&frame, width, ink_top, mid_y);
1165 let second_line_ink = count_non_background_pixels_in_band(&frame, width, mid_y, ink_bottom);
1166 assert!(
1167 first_line_ink > 20,
1168 "expected first line to render, got {first_line_ink}"
1169 );
1170 assert!(
1171 second_line_ink > 20,
1172 "expected second line ink, got {second_line_ink}"
1173 );
1174 }
1175
1176 #[test]
1177 fn draw_scene_renders_graph_backed_scene_without_flat_primitives() {
1178 let mut scene = Scene::new();
1179 scene.graph = Some(RenderGraph::new(LayerNode {
1180 node_id: None,
1181 local_bounds: Rect {
1182 x: 0.0,
1183 y: 0.0,
1184 width: 16.0,
1185 height: 16.0,
1186 },
1187 transform_to_parent: ProjectiveTransform::identity(),
1188 motion_context_animated: false,
1189 translated_content_context: false,
1190 translated_content_offset: cranpose_ui_graphics::Point::default(),
1191 content_offset: cranpose_ui_graphics::Point::default(),
1192 graphics_layer: cranpose_ui_graphics::GraphicsLayer::default(),
1193 clip_to_bounds: false,
1194 shadow_clip: None,
1195 hit_test: None,
1196 has_hit_targets: false,
1197 isolation: IsolationReasons::default(),
1198 cache_policy: CachePolicy::None,
1199 cache_hashes: LayerRasterCacheHashes::default(),
1200 cache_hashes_valid: false,
1201 children: vec![RenderNode::Primitive(PrimitiveEntry {
1202 phase: PrimitivePhase::BeforeChildren,
1203 node: PrimitiveNode::Draw(DrawPrimitiveNode {
1204 primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
1205 rect: Rect {
1206 x: 2.0,
1207 y: 3.0,
1208 width: 6.0,
1209 height: 5.0,
1210 },
1211 brush: Brush::solid(Color::WHITE),
1212 },
1213 clip: None,
1214 }),
1215 })],
1216 }));
1217
1218 let width = 20;
1219 let height = 20;
1220 let mut frame = vec![0u8; (width * height * 4) as usize];
1221 draw_scene(&mut frame, width, height, &scene);
1222
1223 assert!(
1224 count_non_background_pixels(&frame, width, height) > 0,
1225 "graph-backed scenes should render even when flat primitive arrays are empty"
1226 );
1227 }
1228
1229 #[test]
1230 fn text_clip_bounds_prevent_drawing_outside_scroll_window() {
1231 let mut raster_scene = RasterScene::new();
1232 raster_scene.push_text(
1233 2,
1234 Rect {
1235 x: 8.0,
1236 y: 40.0,
1237 width: 180.0,
1238 height: 24.0,
1239 },
1240 Rc::new(cranpose_ui::text::AnnotatedString::from("Clipped Text")),
1241 Color::WHITE,
1242 cranpose_ui::TextStyle::default(),
1243 14.0,
1244 1.0,
1245 cranpose_ui::TextLayoutOptions::default(),
1246 Some(Rect {
1247 x: 0.0,
1248 y: 0.0,
1249 width: 220.0,
1250 height: 20.0,
1251 }),
1252 );
1253
1254 let width = 220;
1255 let height = 100;
1256 let mut frame = vec![0u8; (width * height * 4) as usize];
1257 draw_raster_scene_for_test(&mut frame, width, height, &raster_scene);
1258
1259 let total_ink = count_non_background_pixels_in_band(&frame, width, 0, height);
1260 assert_eq!(
1261 total_ink, 0,
1262 "text should be fully clipped but rendered {total_ink} ink pixels"
1263 );
1264 }
1265
1266 #[test]
1267 fn gradient_brush_contract_requires_visible_color_transition() {
1268 let style = cranpose_ui::TextStyle {
1269 span_style: cranpose_ui::SpanStyle {
1270 brush: Some(Brush::linear_gradient_range(
1271 vec![Color(1.0, 0.0, 0.0, 1.0), Color(0.0, 0.0, 1.0, 1.0)],
1272 cranpose_ui_graphics::Point::new(0.0, 0.0),
1273 cranpose_ui_graphics::Point::new(320.0, 0.0),
1274 )),
1275 ..Default::default()
1276 },
1277 ..Default::default()
1278 };
1279
1280 let (width, _height, frame) = render_single_text_frame(style, Color::WHITE, 12.0);
1281 let left = average_ink_rgb(&frame, width, 20, 150, 20, 120).expect("left ink");
1282 let right = average_ink_rgb(&frame, width, 200, 340, 20, 120).expect("right ink");
1283
1284 assert!(
1285 left[0] > left[2] * 1.15,
1286 "left side should be red-dominant for horizontal gradient, got {left:?}"
1287 );
1288 assert!(
1289 right[2] > right[0] * 1.15,
1290 "right side should be blue-dominant for horizontal gradient, got {right:?}"
1291 );
1292 }
1293
1294 #[test]
1295 fn draw_style_stroke_contract_changes_raster_output() {
1296 let fill_style = cranpose_ui::TextStyle::default();
1297 let stroke_style = cranpose_ui::TextStyle {
1298 span_style: cranpose_ui::SpanStyle {
1299 draw_style: Some(cranpose_ui::text::TextDrawStyle::Stroke { width: 6.0 }),
1300 ..Default::default()
1301 },
1302 ..Default::default()
1303 };
1304
1305 let (width, height, fill_frame) = render_single_text_frame(fill_style, Color::WHITE, 12.0);
1306 let (_, _, stroke_frame) = render_single_text_frame(stroke_style, Color::WHITE, 12.0);
1307 let fill_ink = count_non_background_pixels(&fill_frame, width, height);
1308 let stroke_ink = count_non_background_pixels(&stroke_frame, width, height);
1309
1310 assert_ne!(
1311 fill_frame, stroke_frame,
1312 "Fill and Stroke text must not rasterize identically"
1313 );
1314 assert!(
1315 fill_ink.abs_diff(stroke_ink) > 250,
1316 "Fill/Stroke ink coverage should differ; fill={fill_ink}, stroke={stroke_ink}"
1317 );
1318 }
1319
1320 #[test]
1321 fn shadow_blur_radius_contract_changes_raster_output() {
1322 let base_shadow = cranpose_ui::text::Shadow {
1323 color: Color(0.0, 0.0, 0.0, 0.85),
1324 offset: cranpose_ui_graphics::Point::new(6.0, 4.0),
1325 blur_radius: 0.0,
1326 };
1327 let zero_blur_style = cranpose_ui::TextStyle {
1328 span_style: cranpose_ui::SpanStyle {
1329 shadow: Some(base_shadow),
1330 ..Default::default()
1331 },
1332 ..Default::default()
1333 };
1334 let blurred_style = cranpose_ui::TextStyle {
1335 span_style: cranpose_ui::SpanStyle {
1336 shadow: Some(cranpose_ui::text::Shadow {
1337 blur_radius: 10.0,
1338 ..base_shadow
1339 }),
1340 ..Default::default()
1341 },
1342 ..Default::default()
1343 };
1344
1345 let (_, _, zero_frame) = render_single_text_frame(zero_blur_style, Color::WHITE, 12.0);
1346 let (_, _, blur_frame) = render_single_text_frame(blurred_style, Color::WHITE, 12.0);
1347
1348 assert_ne!(
1349 zero_frame, blur_frame,
1350 "Changing shadow blur radius must change rendered output"
1351 );
1352 }
1353
1354 #[test]
1355 fn text_motion_contract_changes_raster_output() {
1356 let static_style = cranpose_ui::TextStyle {
1357 paragraph_style: cranpose_ui::ParagraphStyle {
1358 text_motion: Some(cranpose_ui::text::TextMotion::Static),
1359 ..Default::default()
1360 },
1361 ..Default::default()
1362 };
1363 let animated_style = cranpose_ui::TextStyle {
1364 paragraph_style: cranpose_ui::ParagraphStyle {
1365 text_motion: Some(cranpose_ui::text::TextMotion::Animated),
1366 ..Default::default()
1367 },
1368 ..Default::default()
1369 };
1370
1371 let (_, _, static_frame) = render_single_text_frame(static_style, Color::WHITE, 12.35);
1372 let (_, _, animated_frame) = render_single_text_frame(animated_style, Color::WHITE, 12.35);
1373
1374 assert_ne!(
1375 static_frame, animated_frame,
1376 "TextMotion::Static and TextMotion::Animated should not rasterize identically"
1377 );
1378 }
1379}