Skip to main content

cranpose_render_pixels/
draw.rs

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        // Note: Borrow<str> lookup doesn't work well with composite key.
151        // We construct key for lookup.
152        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
244// Helper to resolve font size from style
245fn 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    // Source region: either a sub-rect or the full image
551    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    /// Returns `(top_y, bottom_y)` (exclusive) of all non-background ink rows.
1064    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        // Find the y-range of all ink pixels (font-agnostic approach).
1156        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}