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