Skip to main content

cranpose_ui/widgets/
image.rs

1//! Image composable and painter primitives.
2
3#![allow(non_snake_case)]
4#![allow(clippy::too_many_arguments)] // API matches Jetpack Compose Image signature.
5
6use crate::composable;
7use crate::layout::core::{Alignment, Measurable};
8use crate::modifier::{Modifier, Rect, Size};
9use crate::widgets::Layout;
10use cranpose_core::NodeId;
11use cranpose_ui_graphics::{ColorFilter, DrawScope, ImageBitmap, ImageSampling};
12use cranpose_ui_layout::{Constraints, MeasurePolicy, MeasureResult};
13use std::hash::{Hash, Hasher};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::{Arc, Mutex};
16use thiserror::Error;
17
18pub const DEFAULT_ALPHA: f32 = 1.0;
19const SVG_RASTER_CACHE_LIMIT: usize = 8;
20
21static NEXT_SVG_PAINTER_ID: AtomicU64 = AtomicU64::new(1);
22
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub enum ContentScale {
25    Fit,
26    Crop,
27    FillBounds,
28    FillWidth,
29    FillHeight,
30    Inside,
31    None,
32}
33
34impl ContentScale {
35    pub fn scaled_size(self, src_size: Size, dst_size: Size) -> Size {
36        if src_size.width <= 0.0
37            || src_size.height <= 0.0
38            || dst_size.width <= 0.0
39            || dst_size.height <= 0.0
40        {
41            return Size::ZERO;
42        }
43
44        let scale_x = dst_size.width / src_size.width;
45        let scale_y = dst_size.height / src_size.height;
46
47        let (factor_x, factor_y) = match self {
48            Self::Fit => {
49                let factor = scale_x.min(scale_y);
50                (factor, factor)
51            }
52            Self::Crop => {
53                let factor = scale_x.max(scale_y);
54                (factor, factor)
55            }
56            Self::FillBounds => (scale_x, scale_y),
57            Self::FillWidth => (scale_x, scale_x),
58            Self::FillHeight => (scale_y, scale_y),
59            Self::Inside => {
60                if src_size.width <= dst_size.width && src_size.height <= dst_size.height {
61                    (1.0, 1.0)
62                } else {
63                    let factor = scale_x.min(scale_y);
64                    (factor, factor)
65                }
66            }
67            Self::None => (1.0, 1.0),
68        };
69
70        Size {
71            width: src_size.width * factor_x,
72            height: src_size.height * factor_y,
73        }
74    }
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, Hash)]
78pub struct Painter {
79    kind: PainterKind,
80}
81
82#[derive(Clone, Debug, PartialEq, Eq, Hash)]
83enum PainterKind {
84    Bitmap(ImageBitmap),
85    Svg(SvgPainter),
86}
87
88impl Painter {
89    pub fn from_bitmap(bitmap: ImageBitmap) -> Self {
90        Self {
91            kind: PainterKind::Bitmap(bitmap),
92        }
93    }
94
95    pub fn from_svg(svg: SvgPainter) -> Self {
96        Self {
97            kind: PainterKind::Svg(svg),
98        }
99    }
100
101    pub fn intrinsic_size(&self) -> Size {
102        match &self.kind {
103            PainterKind::Bitmap(bitmap) => bitmap.intrinsic_size(),
104            PainterKind::Svg(svg) => svg.intrinsic_size(),
105        }
106    }
107
108    pub fn as_bitmap(&self) -> Option<&ImageBitmap> {
109        match &self.kind {
110            PainterKind::Bitmap(bitmap) => Some(bitmap),
111            PainterKind::Svg(_) => None,
112        }
113    }
114
115    /// Returns the underlying bitmap.
116    ///
117    /// # Panics
118    ///
119    /// Panics if this painter is not backed by an `ImageBitmap`.
120    pub fn bitmap(&self) -> &ImageBitmap {
121        self.as_bitmap()
122            .expect("Painter::bitmap is only available for BitmapPainter values")
123    }
124}
125
126impl From<ImageBitmap> for Painter {
127    fn from(value: ImageBitmap) -> Self {
128        Self::from_bitmap(value)
129    }
130}
131
132impl From<SvgPainter> for Painter {
133    fn from(value: SvgPainter) -> Self {
134        Self::from_svg(value)
135    }
136}
137
138pub fn BitmapPainter(bitmap: ImageBitmap) -> Painter {
139    Painter::from_bitmap(bitmap)
140}
141
142/// Errors returned while parsing or rasterizing an SVG painter.
143#[derive(Debug, Clone, PartialEq, Eq, Error)]
144pub enum SvgPainterError {
145    #[error("failed to parse SVG: {0}")]
146    Parse(String),
147    #[error("SVG raster dimensions must be greater than zero")]
148    InvalidRasterDimensions,
149    #[error("SVG raster dimensions are too large")]
150    RasterDimensionsTooLarge,
151    #[error("failed to allocate SVG raster {width}x{height}")]
152    RasterAllocationFailed { width: u32, height: u32 },
153    #[error("SVG raster cache is unavailable")]
154    RasterCacheUnavailable,
155    #[error(transparent)]
156    ImageBitmap(#[from] cranpose_ui_graphics::ImageBitmapError),
157}
158
159/// Parsed SVG image data that rasterizes on demand for the requested draw size.
160#[derive(Clone)]
161pub struct SvgPainter {
162    inner: Arc<SvgPainterInner>,
163}
164
165struct SvgPainterInner {
166    id: u64,
167    tree: resvg::usvg::Tree,
168    intrinsic_size: Size,
169    cache: Mutex<SvgRasterCache>,
170}
171
172#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
173struct SvgRasterKey {
174    width: u32,
175    height: u32,
176}
177
178#[derive(Clone, Debug)]
179struct SvgRasterEntry {
180    key: SvgRasterKey,
181    bitmap: ImageBitmap,
182}
183
184#[derive(Default, Debug)]
185struct SvgRasterCache {
186    entries: Vec<SvgRasterEntry>,
187}
188
189impl SvgPainter {
190    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SvgPainterError> {
191        let options = resvg::usvg::Options::default();
192        let tree = resvg::usvg::Tree::from_data(bytes, &options)
193            .map_err(|error| SvgPainterError::Parse(error.to_string()))?;
194        let size = tree.size();
195        let intrinsic_size = Size::new(size.width(), size.height());
196
197        Ok(Self {
198            inner: Arc::new(SvgPainterInner {
199                id: NEXT_SVG_PAINTER_ID.fetch_add(1, Ordering::Relaxed),
200                tree,
201                intrinsic_size,
202                cache: Mutex::new(SvgRasterCache::default()),
203            }),
204        })
205    }
206
207    pub fn id(&self) -> u64 {
208        self.inner.id
209    }
210
211    pub fn intrinsic_size(&self) -> Size {
212        self.inner.intrinsic_size
213    }
214
215    pub fn rasterize(&self, pixel_size: Size) -> Result<ImageBitmap, SvgPainterError> {
216        let key = svg_raster_key(pixel_size)?;
217        if let Some(bitmap) = self.cached_bitmap(key)? {
218            return Ok(bitmap);
219        }
220
221        let bitmap = self.rasterize_uncached(key)?;
222        self.cache_bitmap(key, bitmap.clone())?;
223        Ok(bitmap)
224    }
225
226    fn cached_bitmap(&self, key: SvgRasterKey) -> Result<Option<ImageBitmap>, SvgPainterError> {
227        let mut cache = self
228            .inner
229            .cache
230            .lock()
231            .map_err(|_| SvgPainterError::RasterCacheUnavailable)?;
232        Ok(cache.get(key))
233    }
234
235    fn cache_bitmap(&self, key: SvgRasterKey, bitmap: ImageBitmap) -> Result<(), SvgPainterError> {
236        let mut cache = self
237            .inner
238            .cache
239            .lock()
240            .map_err(|_| SvgPainterError::RasterCacheUnavailable)?;
241        cache.insert(key, bitmap);
242        Ok(())
243    }
244
245    fn rasterize_uncached(&self, key: SvgRasterKey) -> Result<ImageBitmap, SvgPainterError> {
246        (key.width as usize)
247            .checked_mul(key.height as usize)
248            .and_then(|value| value.checked_mul(4))
249            .ok_or(SvgPainterError::RasterDimensionsTooLarge)?;
250
251        let mut pixmap = resvg::tiny_skia::Pixmap::new(key.width, key.height).ok_or(
252            SvgPainterError::RasterAllocationFailed {
253                width: key.width,
254                height: key.height,
255            },
256        )?;
257        let transform = resvg::tiny_skia::Transform::from_scale(
258            key.width as f32 / self.inner.intrinsic_size.width,
259            key.height as f32 / self.inner.intrinsic_size.height,
260        );
261        resvg::render(&self.inner.tree, transform, &mut pixmap.as_mut());
262        let pixels = demultiplied_rgba_pixels(&pixmap);
263        Ok(ImageBitmap::from_rgba8(key.width, key.height, pixels)?)
264    }
265}
266
267fn demultiplied_rgba_pixels(pixmap: &resvg::tiny_skia::Pixmap) -> Vec<u8> {
268    pixmap
269        .pixels()
270        .iter()
271        .flat_map(|pixel| {
272            let color = pixel.demultiply();
273            [color.red(), color.green(), color.blue(), color.alpha()]
274        })
275        .collect()
276}
277
278impl std::fmt::Debug for SvgPainter {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        f.debug_struct("SvgPainter")
281            .field("id", &self.id())
282            .field("intrinsic_size", &self.intrinsic_size())
283            .finish_non_exhaustive()
284    }
285}
286
287impl PartialEq for SvgPainter {
288    fn eq(&self, other: &Self) -> bool {
289        self.id() == other.id()
290    }
291}
292
293impl Eq for SvgPainter {}
294
295impl Hash for SvgPainter {
296    fn hash<H: Hasher>(&self, state: &mut H) {
297        self.id().hash(state);
298    }
299}
300
301impl SvgRasterCache {
302    fn get(&mut self, key: SvgRasterKey) -> Option<ImageBitmap> {
303        let position = self.entries.iter().position(|entry| entry.key == key)?;
304        let entry = self.entries.remove(position);
305        let bitmap = entry.bitmap.clone();
306        self.entries.push(entry);
307        Some(bitmap)
308    }
309
310    fn insert(&mut self, key: SvgRasterKey, bitmap: ImageBitmap) {
311        if let Some(position) = self.entries.iter().position(|entry| entry.key == key) {
312            self.entries.remove(position);
313        } else if self.entries.len() >= SVG_RASTER_CACHE_LIMIT {
314            self.entries.remove(0);
315        }
316        self.entries.push(SvgRasterEntry { key, bitmap });
317    }
318}
319
320#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
321struct SvgBytesKey {
322    ptr: usize,
323    len: usize,
324}
325
326impl SvgBytesKey {
327    fn new(bytes: &'static [u8]) -> Self {
328        Self {
329            ptr: bytes.as_ptr() as usize,
330            len: bytes.len(),
331        }
332    }
333}
334
335pub fn remember_svg(bytes: &'static [u8]) -> Result<SvgPainter, SvgPainterError> {
336    let key = SvgBytesKey::new(bytes);
337    cranpose_core::withCurrentComposer(|composer| {
338        composer.with_key(&key, |composer| {
339            composer
340                .remember(|| SvgPainter::from_bytes(bytes))
341                .with(|result| result.clone())
342        })
343    })
344}
345
346/// Measure policy for Image that preserves aspect ratio when constraints
347/// force the image smaller than its intrinsic size.
348///
349/// Unlike [`LeafMeasurePolicy`] which clamps width and height independently,
350/// this scales both dimensions by the same factor so the image is never
351/// distorted by layout constraints.
352#[derive(Clone, Debug, PartialEq)]
353struct ImageMeasurePolicy {
354    intrinsic_size: Size,
355}
356
357impl MeasurePolicy for ImageMeasurePolicy {
358    fn measure(
359        &self,
360        _measurables: &[Box<dyn Measurable>],
361        constraints: Constraints,
362    ) -> MeasureResult {
363        let iw = self.intrinsic_size.width;
364        let ih = self.intrinsic_size.height;
365
366        if iw <= 0.0 || ih <= 0.0 {
367            let (w, h) = constraints.constrain(0.0, 0.0);
368            return MeasureResult::new(
369                Size {
370                    width: w,
371                    height: h,
372                },
373                vec![],
374            );
375        }
376
377        // Clamp each axis to its constraint range.
378        let cw = iw.clamp(constraints.min_width, constraints.max_width);
379        let ch = ih.clamp(constraints.min_height, constraints.max_height);
380
381        // If either axis had to shrink, scale both by the smaller factor
382        // so the aspect ratio is preserved.
383        let scale_x = cw / iw;
384        let scale_y = ch / ih;
385
386        let (width, height) = if scale_x < 1.0 || scale_y < 1.0 {
387            let factor = scale_x.min(scale_y);
388            let w = (iw * factor).clamp(constraints.min_width, constraints.max_width);
389            let h = (ih * factor).clamp(constraints.min_height, constraints.max_height);
390            (w, h)
391        } else {
392            (cw, ch)
393        };
394
395        MeasureResult::new(Size { width, height }, vec![])
396    }
397
398    fn min_intrinsic_width(&self, _measurables: &[Box<dyn Measurable>], _height: f32) -> f32 {
399        self.intrinsic_size.width
400    }
401
402    fn max_intrinsic_width(&self, _measurables: &[Box<dyn Measurable>], _height: f32) -> f32 {
403        self.intrinsic_size.width
404    }
405
406    fn min_intrinsic_height(&self, _measurables: &[Box<dyn Measurable>], _width: f32) -> f32 {
407        self.intrinsic_size.height
408    }
409
410    fn max_intrinsic_height(&self, _measurables: &[Box<dyn Measurable>], _width: f32) -> f32 {
411        self.intrinsic_size.height
412    }
413}
414
415fn destination_rect(
416    src_size: Size,
417    dst_size: Size,
418    alignment: Alignment,
419    content_scale: ContentScale,
420) -> Rect {
421    let draw_size = content_scale.scaled_size(src_size, dst_size);
422    let allows_overflow = content_scale == ContentScale::Crop;
423    let offset_x = aligned_x_offset(
424        alignment.horizontal,
425        dst_size.width,
426        draw_size.width,
427        allows_overflow,
428    );
429    let offset_y = aligned_y_offset(
430        alignment.vertical,
431        dst_size.height,
432        draw_size.height,
433        allows_overflow,
434    );
435    Rect {
436        x: offset_x,
437        y: offset_y,
438        width: draw_size.width,
439        height: draw_size.height,
440    }
441}
442
443fn aligned_x_offset(
444    alignment: cranpose_ui_layout::HorizontalAlignment,
445    available: f32,
446    child: f32,
447    allows_overflow: bool,
448) -> f32 {
449    if !allows_overflow {
450        return alignment.align(available, child);
451    }
452
453    match alignment {
454        cranpose_ui_layout::HorizontalAlignment::Start => 0.0,
455        cranpose_ui_layout::HorizontalAlignment::CenterHorizontally => (available - child) / 2.0,
456        cranpose_ui_layout::HorizontalAlignment::End => available - child,
457    }
458}
459
460fn aligned_y_offset(
461    alignment: cranpose_ui_layout::VerticalAlignment,
462    available: f32,
463    child: f32,
464    allows_overflow: bool,
465) -> f32 {
466    if !allows_overflow {
467        return alignment.align(available, child);
468    }
469
470    match alignment {
471        cranpose_ui_layout::VerticalAlignment::Top => 0.0,
472        cranpose_ui_layout::VerticalAlignment::CenterVertically => (available - child) / 2.0,
473        cranpose_ui_layout::VerticalAlignment::Bottom => available - child,
474    }
475}
476
477fn map_destination_clip_to_source(
478    src_rect: Rect,
479    dst_rect: Rect,
480    clipped_dst_rect: Rect,
481) -> Option<Rect> {
482    if src_rect.width <= 0.0
483        || src_rect.height <= 0.0
484        || dst_rect.width <= 0.0
485        || dst_rect.height <= 0.0
486        || clipped_dst_rect.width <= 0.0
487        || clipped_dst_rect.height <= 0.0
488    {
489        return None;
490    }
491
492    let scale_x = src_rect.width / dst_rect.width;
493    let scale_y = src_rect.height / dst_rect.height;
494
495    let src_min_x = src_rect.x;
496    let src_min_y = src_rect.y;
497    let src_max_x = src_rect.x + src_rect.width;
498    let src_max_y = src_rect.y + src_rect.height;
499
500    let raw_left = src_rect.x + (clipped_dst_rect.x - dst_rect.x) * scale_x;
501    let raw_top = src_rect.y + (clipped_dst_rect.y - dst_rect.y) * scale_y;
502    let raw_right =
503        src_rect.x + ((clipped_dst_rect.x + clipped_dst_rect.width) - dst_rect.x) * scale_x;
504    let raw_bottom =
505        src_rect.y + ((clipped_dst_rect.y + clipped_dst_rect.height) - dst_rect.y) * scale_y;
506
507    let left = raw_left.clamp(src_min_x, src_max_x);
508    let top = raw_top.clamp(src_min_y, src_max_y);
509    let right = raw_right.clamp(src_min_x, src_max_x);
510    let bottom = raw_bottom.clamp(src_min_y, src_max_y);
511    let width = right - left;
512    let height = bottom - top;
513
514    if width <= 0.0 || height <= 0.0 {
515        None
516    } else {
517        Some(Rect {
518            x: left,
519            y: top,
520            width,
521            height,
522        })
523    }
524}
525
526fn image_destination_clip(
527    src_size: Size,
528    container_size: Size,
529    alignment: Alignment,
530    content_scale: ContentScale,
531) -> Option<(Rect, Rect)> {
532    let dst_rect = destination_rect(src_size, container_size, alignment, content_scale);
533    if dst_rect.width <= 0.0 || dst_rect.height <= 0.0 {
534        return None;
535    }
536
537    let container_rect = Rect::from_size(container_size);
538    let clipped_dst_rect = dst_rect.intersect(container_rect)?;
539    Some((dst_rect, clipped_dst_rect))
540}
541
542fn draw_bitmap_painter(
543    scope: &mut dyn DrawScope,
544    bitmap: ImageBitmap,
545    intrinsic_size: Size,
546    alignment: Alignment,
547    content_scale: ContentScale,
548    alpha: f32,
549    color_filter: Option<ColorFilter>,
550) {
551    let container_size = scope.size();
552    let Some((dst_rect, clipped_dst_rect)) =
553        image_destination_clip(intrinsic_size, container_size, alignment, content_scale)
554    else {
555        return;
556    };
557    let full_src_rect = Rect::from_size(Size::new(bitmap.width() as f32, bitmap.height() as f32));
558    let Some(clipped_src_rect) =
559        map_destination_clip_to_source(full_src_rect, dst_rect, clipped_dst_rect)
560    else {
561        return;
562    };
563    scope.draw_image_src_sampled(
564        bitmap,
565        clipped_src_rect,
566        clipped_dst_rect,
567        alpha,
568        color_filter,
569        ImageSampling::Linear,
570    );
571}
572
573fn draw_svg_painter(
574    scope: &mut dyn DrawScope,
575    svg: SvgPainter,
576    intrinsic_size: Size,
577    alignment: Alignment,
578    content_scale: ContentScale,
579    alpha: f32,
580    color_filter: Option<ColorFilter>,
581) {
582    let container_size = scope.size();
583    let Some((dst_rect, clipped_dst_rect)) =
584        image_destination_clip(intrinsic_size, container_size, alignment, content_scale)
585    else {
586        return;
587    };
588    let density = crate::render_state::current_density();
589    let pixel_size = Size::new(dst_rect.width * density, dst_rect.height * density);
590    let bitmap = match svg.rasterize(pixel_size) {
591        Ok(bitmap) => bitmap,
592        Err(error) => {
593            log::warn!("failed to rasterize SVG painter: {error}");
594            return;
595        }
596    };
597    let full_src_rect = Rect::from_size(Size::new(bitmap.width() as f32, bitmap.height() as f32));
598    let Some(clipped_src_rect) =
599        map_destination_clip_to_source(full_src_rect, dst_rect, clipped_dst_rect)
600    else {
601        return;
602    };
603    scope.draw_image_src_sampled(
604        bitmap,
605        clipped_src_rect,
606        clipped_dst_rect,
607        alpha,
608        color_filter,
609        ImageSampling::Linear,
610    );
611}
612
613fn svg_raster_key(pixel_size: Size) -> Result<SvgRasterKey, SvgPainterError> {
614    let width = svg_raster_axis(pixel_size.width)?;
615    let height = svg_raster_axis(pixel_size.height)?;
616    width
617        .checked_mul(height)
618        .and_then(|value| value.checked_mul(4))
619        .ok_or(SvgPainterError::RasterDimensionsTooLarge)?;
620    Ok(SvgRasterKey { width, height })
621}
622
623fn svg_raster_axis(value: f32) -> Result<u32, SvgPainterError> {
624    if !value.is_finite() || value <= 0.0 {
625        return Err(SvgPainterError::InvalidRasterDimensions);
626    }
627
628    let rounded = value.ceil();
629    if rounded > u32::MAX as f32 {
630        return Err(SvgPainterError::RasterDimensionsTooLarge);
631    }
632    Ok(rounded as u32)
633}
634
635#[composable]
636pub fn Image<P>(
637    painter: P,
638    content_description: Option<String>,
639    modifier: Modifier,
640    alignment: Alignment,
641    content_scale: ContentScale,
642    alpha: f32,
643    color_filter: Option<ColorFilter>,
644) -> NodeId
645where
646    P: Into<Painter> + Clone + PartialEq + 'static,
647{
648    let painter = painter.into();
649    // Painter intrinsic units are logical dp. Bitmap painters use 1 source pixel
650    // per dp; SVG painters rasterize at draw size and current density.
651    let intrinsic_dp = painter.intrinsic_size();
652    let draw_alpha = alpha.clamp(0.0, 1.0);
653    let draw_painter = painter.clone();
654
655    let semantics_modifier = if let Some(description) = content_description {
656        Modifier::empty().semantics(move |config| {
657            config.content_description = Some(description.clone());
658        })
659    } else {
660        Modifier::empty()
661    };
662
663    let image_modifier =
664        modifier
665            .then(semantics_modifier)
666            .draw_behind(move |scope: &mut dyn DrawScope| {
667                if draw_alpha <= 0.0 {
668                    return;
669                }
670                let container_size = scope.size();
671                if container_size.width <= 0.0 || container_size.height <= 0.0 {
672                    return;
673                }
674                match &draw_painter.kind {
675                    PainterKind::Bitmap(bitmap) => draw_bitmap_painter(
676                        scope,
677                        bitmap.clone(),
678                        intrinsic_dp,
679                        alignment,
680                        content_scale,
681                        draw_alpha,
682                        color_filter,
683                    ),
684                    PainterKind::Svg(svg) => draw_svg_painter(
685                        scope,
686                        svg.clone(),
687                        intrinsic_dp,
688                        alignment,
689                        content_scale,
690                        draw_alpha,
691                        color_filter,
692                    ),
693                }
694            });
695
696    Layout(
697        image_modifier,
698        ImageMeasurePolicy {
699            intrinsic_size: intrinsic_dp,
700        },
701        || {},
702    )
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use crate::layout::core::Alignment;
709
710    const RED_RECT_SVG: &[u8] = br##"
711        <svg xmlns="http://www.w3.org/2000/svg" width="10" height="20" viewBox="0 0 10 20">
712          <rect x="0" y="0" width="10" height="20" fill="#ff0000"/>
713        </svg>
714    "##;
715
716    const TRANSPARENT_CENTER_SVG: &[u8] = br##"
717        <svg xmlns="http://www.w3.org/2000/svg" width="4" height="4" viewBox="0 0 4 4">
718          <rect x="1" y="1" width="2" height="2" fill="#00ff00"/>
719        </svg>
720    "##;
721
722    fn sample_bitmap() -> ImageBitmap {
723        ImageBitmap::from_rgba8(4, 2, vec![255; 4 * 2 * 4]).expect("bitmap")
724    }
725
726    fn cache_test_bitmap(width: u32) -> ImageBitmap {
727        ImageBitmap::from_rgba8(width, 1, vec![255; width as usize * 4]).expect("bitmap")
728    }
729
730    fn pixel_at(bitmap: &ImageBitmap, x: u32, y: u32) -> [u8; 4] {
731        let offset = ((y * bitmap.width() + x) * 4) as usize;
732        let pixels = bitmap.pixels();
733        [
734            pixels[offset],
735            pixels[offset + 1],
736            pixels[offset + 2],
737            pixels[offset + 3],
738        ]
739    }
740
741    #[test]
742    fn painter_reports_intrinsic_size_and_bitmap() {
743        let bitmap = sample_bitmap();
744        let painter = BitmapPainter(bitmap.clone());
745        assert_eq!(painter.intrinsic_size(), Size::new(4.0, 2.0));
746        assert_eq!(painter.bitmap(), &bitmap);
747    }
748
749    #[test]
750    fn svg_painter_reports_intrinsic_size() {
751        let painter = SvgPainter::from_bytes(RED_RECT_SVG).expect("svg painter");
752        assert_eq!(painter.intrinsic_size(), Size::new(10.0, 20.0));
753    }
754
755    #[test]
756    fn svg_painter_rasterizes_requested_dimensions() {
757        let painter = SvgPainter::from_bytes(RED_RECT_SVG).expect("svg painter");
758        let bitmap = painter
759            .rasterize(Size::new(5.0, 10.0))
760            .expect("rasterized svg");
761
762        assert_eq!(bitmap.width(), 5);
763        assert_eq!(bitmap.height(), 10);
764        assert_eq!(pixel_at(&bitmap, 2, 5), [255, 0, 0, 255]);
765    }
766
767    #[test]
768    fn svg_painter_preserves_transparency() {
769        let painter = SvgPainter::from_bytes(TRANSPARENT_CENTER_SVG).expect("svg painter");
770        let bitmap = painter
771            .rasterize(Size::new(4.0, 4.0))
772            .expect("rasterized svg");
773
774        assert_eq!(pixel_at(&bitmap, 0, 0), [0, 0, 0, 0]);
775        assert_eq!(pixel_at(&bitmap, 2, 2), [0, 255, 0, 255]);
776    }
777
778    #[test]
779    fn svg_painter_reuses_cached_raster_for_same_size() {
780        let painter = SvgPainter::from_bytes(RED_RECT_SVG).expect("svg painter");
781        let first = painter
782            .rasterize(Size::new(8.0, 8.0))
783            .expect("first raster");
784        let second = painter
785            .rasterize(Size::new(8.0, 8.0))
786            .expect("second raster");
787
788        assert_eq!(first.id(), second.id());
789    }
790
791    #[test]
792    fn svg_raster_cache_evicts_least_recently_used_entry() {
793        let mut cache = SvgRasterCache::default();
794        let keys: Vec<SvgRasterKey> = (0..SVG_RASTER_CACHE_LIMIT)
795            .map(|index| SvgRasterKey {
796                width: index as u32 + 1,
797                height: 1,
798            })
799            .collect();
800
801        for key in &keys {
802            cache.insert(*key, cache_test_bitmap(key.width));
803        }
804
805        let recent = cache.get(keys[0]).expect("cached raster");
806        let new_key = SvgRasterKey {
807            width: SVG_RASTER_CACHE_LIMIT as u32 + 1,
808            height: 1,
809        };
810        cache.insert(new_key, cache_test_bitmap(new_key.width));
811
812        assert!(cache.get(keys[1]).is_none());
813        assert_eq!(
814            cache.get(keys[0]).expect("retained raster").id(),
815            recent.id()
816        );
817        assert!(cache.get(new_key).is_some());
818    }
819
820    #[test]
821    fn svg_painter_rasterizes_distinct_sizes_separately() {
822        let painter = SvgPainter::from_bytes(RED_RECT_SVG).expect("svg painter");
823        let small = painter
824            .rasterize(Size::new(8.0, 8.0))
825            .expect("small raster");
826        let large = painter
827            .rasterize(Size::new(16.0, 16.0))
828            .expect("large raster");
829
830        assert_ne!(small.id(), large.id());
831        assert_eq!(large.width(), 16);
832        assert_eq!(large.height(), 16);
833    }
834
835    #[test]
836    fn svg_painter_rejects_invalid_bytes() {
837        let err = SvgPainter::from_bytes(b"not svg").expect_err("invalid svg");
838        assert!(matches!(err, SvgPainterError::Parse(_)));
839    }
840
841    #[test]
842    fn fit_keeps_aspect_ratio() {
843        let src = Size::new(200.0, 100.0);
844        let dst = Size::new(300.0, 300.0);
845        let result = ContentScale::Fit.scaled_size(src, dst);
846        assert_eq!(result, Size::new(300.0, 150.0));
847    }
848
849    #[test]
850    fn crop_fills_bounds() {
851        let src = Size::new(200.0, 100.0);
852        let dst = Size::new(300.0, 300.0);
853        let result = ContentScale::Crop.scaled_size(src, dst);
854        assert_eq!(result, Size::new(600.0, 300.0));
855    }
856
857    #[test]
858    fn destination_rect_aligns_center() {
859        let src = Size::new(200.0, 100.0);
860        let dst = Size::new(300.0, 300.0);
861        let rect = destination_rect(src, dst, Alignment::CENTER, ContentScale::Fit);
862        assert_eq!(
863            rect,
864            Rect {
865                x: 0.0,
866                y: 75.0,
867                width: 300.0,
868                height: 150.0,
869            }
870        );
871    }
872
873    #[test]
874    fn crop_destination_clip_maps_centered_wide_source() {
875        let src = Size::new(200.0, 100.0);
876        let dst = Size::new(100.0, 100.0);
877        let (dst_rect, clipped_dst_rect) =
878            image_destination_clip(src, dst, Alignment::CENTER, ContentScale::Crop)
879                .expect("destination clip");
880        let rect = map_destination_clip_to_source(Rect::from_size(src), dst_rect, clipped_dst_rect)
881            .expect("source clip");
882        assert_eq!(
883            rect,
884            Rect {
885                x: 50.0,
886                y: 0.0,
887                width: 100.0,
888                height: 100.0,
889            }
890        );
891    }
892
893    #[test]
894    fn crop_destination_clip_honors_start_alignment() {
895        let src = Size::new(200.0, 100.0);
896        let dst = Size::new(100.0, 100.0);
897        let (dst_rect, clipped_dst_rect) =
898            image_destination_clip(src, dst, Alignment::TOP_START, ContentScale::Crop)
899                .expect("destination clip");
900        let rect = map_destination_clip_to_source(Rect::from_size(src), dst_rect, clipped_dst_rect)
901            .expect("source clip");
902        assert_eq!(
903            rect,
904            Rect {
905                x: 0.0,
906                y: 0.0,
907                width: 100.0,
908                height: 100.0,
909            }
910        );
911    }
912
913    fn approx_eq(left: f32, right: f32) {
914        assert!((left - right).abs() < 1e-4, "left={left}, right={right}");
915    }
916
917    #[test]
918    fn map_destination_clip_to_source_scales_proportionally() {
919        let src = Rect {
920            x: 0.0,
921            y: 0.0,
922            width: 100.0,
923            height: 100.0,
924        };
925        let dst = Rect {
926            x: -50.0,
927            y: 0.0,
928            width: 200.0,
929            height: 100.0,
930        };
931        let clipped_dst = Rect {
932            x: 0.0,
933            y: 0.0,
934            width: 100.0,
935            height: 100.0,
936        };
937        let mapped = map_destination_clip_to_source(src, dst, clipped_dst).expect("mapped");
938        approx_eq(mapped.x, 25.0);
939        approx_eq(mapped.y, 0.0);
940        approx_eq(mapped.width, 50.0);
941        approx_eq(mapped.height, 100.0);
942    }
943
944    #[test]
945    fn map_destination_clip_to_source_returns_full_source_without_clipping() {
946        let src = Rect {
947            x: 0.0,
948            y: 0.0,
949            width: 120.0,
950            height: 80.0,
951        };
952        let dst = Rect {
953            x: 10.0,
954            y: 5.0,
955            width: 60.0,
956            height: 40.0,
957        };
958        let mapped = map_destination_clip_to_source(src, dst, dst).expect("mapped");
959        approx_eq(mapped.x, src.x);
960        approx_eq(mapped.y, src.y);
961        approx_eq(mapped.width, src.width);
962        approx_eq(mapped.height, src.height);
963    }
964
965    // --- ImageMeasurePolicy tests ---
966
967    fn measure_image(intrinsic: Size, constraints: Constraints) -> Size {
968        let policy = ImageMeasurePolicy {
969            intrinsic_size: intrinsic,
970        };
971        policy.measure(&[], constraints).size
972    }
973
974    #[test]
975    fn image_measure_unconstrained() {
976        let size = measure_image(
977            Size::new(800.0, 600.0),
978            Constraints::loose(f32::INFINITY, f32::INFINITY),
979        );
980        assert_eq!(size, Size::new(800.0, 600.0));
981    }
982
983    #[test]
984    fn image_measure_width_constrained_preserves_aspect_ratio() {
985        // 800×600 constrained to max_width=400 → should scale to 400×300
986        let size = measure_image(
987            Size::new(800.0, 600.0),
988            Constraints::loose(400.0, f32::INFINITY),
989        );
990        assert_eq!(size, Size::new(400.0, 300.0));
991    }
992
993    #[test]
994    fn image_measure_height_constrained_preserves_aspect_ratio() {
995        // 800×600 constrained to max_height=300 → should scale to 400×300
996        let size = measure_image(
997            Size::new(800.0, 600.0),
998            Constraints::loose(f32::INFINITY, 300.0),
999        );
1000        assert_eq!(size, Size::new(400.0, 300.0));
1001    }
1002
1003    #[test]
1004    fn image_measure_both_constrained_uses_smaller_factor() {
1005        // 800×600 constrained to 200×400 → width is the bottleneck (0.25)
1006        // scaled: 200×150
1007        let size = measure_image(Size::new(800.0, 600.0), Constraints::loose(200.0, 400.0));
1008        assert_eq!(size, Size::new(200.0, 150.0));
1009    }
1010
1011    #[test]
1012    fn image_measure_fits_within_constraints() {
1013        // 200×100 in a 400×400 container → stays at intrinsic size
1014        let size = measure_image(Size::new(200.0, 100.0), Constraints::loose(400.0, 400.0));
1015        assert_eq!(size, Size::new(200.0, 100.0));
1016    }
1017
1018    #[test]
1019    fn image_measure_zero_intrinsic() {
1020        let size = measure_image(Size::ZERO, Constraints::loose(400.0, 400.0));
1021        assert_eq!(size, Size::new(0.0, 0.0));
1022    }
1023}