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::nine_patch::{nine_patch_quads, tile_quads, NinePatchInsets, PatchFill, PatchQuad};
10use crate::widgets::Layout;
11use cranpose_core::NodeId;
12use cranpose_ui_graphics::{ColorFilter, DrawScope, ImageBitmap, ImageSampling};
13use cranpose_ui_layout::{Constraints, MeasurePolicy, MeasureResult, MeasureScope, Placement};
14use std::hash::{Hash, Hasher};
15use std::sync::Arc;
16#[cfg(feature = "svg")]
17use std::sync::{Mutex, MutexGuard};
18use thiserror::Error;
19
20#[cfg(feature = "svg")]
21#[path = "image_svg.rs"]
22mod image_svg;
23
24pub const DEFAULT_ALPHA: f32 = 1.0;
25#[cfg(feature = "svg")]
26const SVG_RASTER_CACHE_LIMIT: usize = 8;
27
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub enum ContentScale {
30    Fit,
31    Crop,
32    FillBounds,
33    FillWidth,
34    FillHeight,
35    Inside,
36    None,
37}
38
39impl ContentScale {
40    pub fn scaled_size(self, src_size: Size, dst_size: Size) -> Size {
41        if src_size.width <= 0.0
42            || src_size.height <= 0.0
43            || dst_size.width <= 0.0
44            || dst_size.height <= 0.0
45        {
46            return Size::ZERO;
47        }
48
49        let scale_x = dst_size.width / src_size.width;
50        let scale_y = dst_size.height / src_size.height;
51
52        let (factor_x, factor_y) = match self {
53            Self::Fit => {
54                let factor = scale_x.min(scale_y);
55                (factor, factor)
56            }
57            Self::Crop => {
58                let factor = scale_x.max(scale_y);
59                (factor, factor)
60            }
61            Self::FillBounds => (scale_x, scale_y),
62            Self::FillWidth => (scale_x, scale_x),
63            Self::FillHeight => (scale_y, scale_y),
64            Self::Inside => {
65                if src_size.width <= dst_size.width && src_size.height <= dst_size.height {
66                    (1.0, 1.0)
67                } else {
68                    let factor = scale_x.min(scale_y);
69                    (factor, factor)
70                }
71            }
72            Self::None => (1.0, 1.0),
73        };
74
75        Size {
76            width: src_size.width * factor_x,
77            height: src_size.height * factor_y,
78        }
79    }
80}
81
82#[derive(Clone, Debug, PartialEq, Eq, Hash)]
83pub struct Painter {
84    kind: PainterKind,
85}
86
87#[derive(Clone, Debug, PartialEq, Eq, Hash)]
88enum PainterKind {
89    Bitmap(ImageBitmap),
90    BitmapRegion {
91        bitmap: ImageBitmap,
92        source: Quad,
93        sampling: ImageSampling,
94    },
95    /// A source repeated at its own size across whatever space it is given,
96    /// rather than scaled to fit it.
97    BitmapTiled {
98        bitmap: ImageBitmap,
99        source: Quad,
100        sampling: ImageSampling,
101    },
102    /// A source whose corners keep their size while its edges and middle grow.
103    NinePatch {
104        bitmap: ImageBitmap,
105        source: Quad,
106        insets: Quad,
107        center: PatchFill,
108        edges: PatchFill,
109        sampling: ImageSampling,
110    },
111    Svg(SvgPainter),
112}
113
114/// Four `f32`s carried as bits.
115///
116/// A painter is compared and hashed so a composable can skip when it has not
117/// changed, and floats are neither `Eq` nor `Hash`. Keeping the bits makes the
118/// comparison exact — two painters built from the same numbers are the same
119/// painter — without asking the caller to think about it.
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
121struct Quad {
122    a: u32,
123    b: u32,
124    c: u32,
125    d: u32,
126}
127
128impl Quad {
129    fn of(a: f32, b: f32, c: f32, d: f32) -> Self {
130        Self {
131            a: a.to_bits(),
132            b: b.to_bits(),
133            c: c.to_bits(),
134            d: d.to_bits(),
135        }
136    }
137
138    fn values(self) -> (f32, f32, f32, f32) {
139        (
140            f32::from_bits(self.a),
141            f32::from_bits(self.b),
142            f32::from_bits(self.c),
143            f32::from_bits(self.d),
144        )
145    }
146
147    fn from_rect(source: Rect) -> Self {
148        Self::of(source.x, source.y, source.width, source.height)
149    }
150
151    fn rect(self) -> Rect {
152        let (x, y, width, height) = self.values();
153        Rect {
154            x,
155            y,
156            width,
157            height,
158        }
159    }
160
161    fn from_insets(insets: NinePatchInsets) -> Self {
162        Self::of(insets.left, insets.top, insets.right, insets.bottom)
163    }
164
165    fn insets(self) -> NinePatchInsets {
166        let (left, top, right, bottom) = self.values();
167        NinePatchInsets::new(left, top, right, bottom)
168    }
169}
170
171impl Painter {
172    pub fn from_bitmap(bitmap: ImageBitmap) -> Self {
173        Self {
174            kind: PainterKind::Bitmap(bitmap),
175        }
176    }
177
178    /// Creates a painter for one source region of a bitmap atlas.
179    pub fn from_bitmap_region(bitmap: ImageBitmap, source: Rect, sampling: ImageSampling) -> Self {
180        Self {
181            kind: PainterKind::BitmapRegion {
182                bitmap,
183                source: Quad::from_rect(source),
184                sampling,
185            },
186        }
187    }
188
189    /// Creates a painter that repeats `source` at its own size across whatever
190    /// space it is given, instead of scaling it to fit.
191    ///
192    /// A texture, a hatch or a skin background covers an area of any size
193    /// without going soft. `source` is a region of `bitmap`, so an application
194    /// tiles one sprite out of an atlas and never the whole sheet.
195    pub fn from_bitmap_tiled(bitmap: ImageBitmap, source: Rect, sampling: ImageSampling) -> Self {
196        Self {
197            kind: PainterKind::BitmapTiled {
198                bitmap,
199                source: Quad::from_rect(source),
200                sampling,
201            },
202        }
203    }
204
205    /// Creates a painter whose corners keep their own size while its edges and
206    /// middle grow — one drawing of a button, panel or trough used at every
207    /// size.
208    ///
209    /// `center` and `edges` decide whether the growing parts are stretched or
210    /// tiled. Insets that leave no middle, or a destination with no room for
211    /// the corners, fall back to a plain scale rather than drawing corners over
212    /// each other.
213    pub fn from_nine_patch(
214        bitmap: ImageBitmap,
215        source: Rect,
216        insets: NinePatchInsets,
217        center: PatchFill,
218        edges: PatchFill,
219        sampling: ImageSampling,
220    ) -> Self {
221        Self {
222            kind: PainterKind::NinePatch {
223                bitmap,
224                source: Quad::from_rect(source),
225                insets: Quad::from_insets(insets),
226                center,
227                edges,
228                sampling,
229            },
230        }
231    }
232
233    pub fn from_svg(svg: SvgPainter) -> Self {
234        Self {
235            kind: PainterKind::Svg(svg),
236        }
237    }
238
239    pub fn intrinsic_size(&self) -> Size {
240        match &self.kind {
241            PainterKind::Bitmap(bitmap) => bitmap.intrinsic_size(),
242            PainterKind::BitmapRegion { source, .. }
243            | PainterKind::BitmapTiled { source, .. }
244            | PainterKind::NinePatch { source, .. } => {
245                let source = source.rect();
246                Size::new(source.width.max(0.0), source.height.max(0.0))
247            }
248            PainterKind::Svg(svg) => svg.intrinsic_size(),
249        }
250    }
251
252    pub fn as_bitmap(&self) -> Option<&ImageBitmap> {
253        match &self.kind {
254            PainterKind::Bitmap(bitmap) => Some(bitmap),
255            PainterKind::BitmapRegion { bitmap, .. }
256            | PainterKind::BitmapTiled { bitmap, .. }
257            | PainterKind::NinePatch { bitmap, .. } => Some(bitmap),
258            PainterKind::Svg(_) => None,
259        }
260    }
261
262    /// Returns the underlying bitmap when this painter is bitmap-backed.
263    pub fn bitmap(&self) -> Option<&ImageBitmap> {
264        self.as_bitmap()
265    }
266}
267
268impl From<ImageBitmap> for Painter {
269    fn from(value: ImageBitmap) -> Self {
270        Self::from_bitmap(value)
271    }
272}
273
274impl From<SvgPainter> for Painter {
275    fn from(value: SvgPainter) -> Self {
276        Self::from_svg(value)
277    }
278}
279
280pub fn BitmapPainter(bitmap: ImageBitmap) -> Painter {
281    Painter::from_bitmap(bitmap)
282}
283
284/// Creates a painter that draws one region from a bitmap atlas.
285pub fn BitmapRegionPainter(bitmap: ImageBitmap, source: Rect, sampling: ImageSampling) -> Painter {
286    Painter::from_bitmap_region(bitmap, source, sampling)
287}
288
289/// Creates a painter that repeats one region of a bitmap across its bounds.
290pub fn TiledPainter(bitmap: ImageBitmap, source: Rect, sampling: ImageSampling) -> Painter {
291    Painter::from_bitmap_tiled(bitmap, source, sampling)
292}
293
294/// Creates a painter whose corners stay put while its edges and middle grow.
295pub fn NinePatchPainter(
296    bitmap: ImageBitmap,
297    source: Rect,
298    insets: NinePatchInsets,
299    center: PatchFill,
300    edges: PatchFill,
301    sampling: ImageSampling,
302) -> Painter {
303    Painter::from_nine_patch(bitmap, source, insets, center, edges, sampling)
304}
305
306/// Errors returned while parsing or rasterizing an SVG painter.
307#[derive(Debug, Clone, PartialEq, Eq, Error)]
308pub enum SvgPainterError {
309    #[error("SVG support is disabled; enable the cranpose-ui `svg` feature")]
310    SvgFeatureDisabled,
311    #[error("failed to parse SVG: {0}")]
312    Parse(String),
313    #[error("SVG raster dimensions must be greater than zero")]
314    InvalidRasterDimensions,
315    #[error("SVG raster dimensions are too large")]
316    RasterDimensionsTooLarge,
317    #[error("failed to allocate SVG raster {width}x{height}")]
318    RasterAllocationFailed { width: u32, height: u32 },
319    #[error("SVG raster cache is unavailable")]
320    RasterCacheUnavailable,
321    #[error(transparent)]
322    ImageBitmap(#[from] cranpose_ui_graphics::ImageBitmapError),
323}
324
325/// Parsed SVG image data that rasterizes on demand for the requested draw size.
326#[derive(Clone)]
327pub struct SvgPainter {
328    inner: Arc<SvgPainterInner>,
329}
330
331struct SvgPainterInner {
332    #[cfg(feature = "svg")]
333    document: image_svg::SvgDocument,
334    intrinsic_size: Size,
335    #[cfg(feature = "svg")]
336    cache: Mutex<SvgRasterCache>,
337}
338
339#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
340#[cfg(feature = "svg")]
341struct SvgRasterKey {
342    width: u32,
343    height: u32,
344}
345
346#[derive(Clone, Debug)]
347#[cfg(feature = "svg")]
348struct SvgRasterEntry {
349    key: SvgRasterKey,
350    bitmap: ImageBitmap,
351}
352
353#[derive(Default, Debug)]
354#[cfg(feature = "svg")]
355struct SvgRasterCache {
356    entries: Vec<SvgRasterEntry>,
357}
358
359impl SvgPainter {
360    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SvgPainterError> {
361        #[cfg(not(feature = "svg"))]
362        {
363            let _ = bytes;
364            Err(SvgPainterError::SvgFeatureDisabled)
365        }
366        #[cfg(feature = "svg")]
367        {
368            let document = image_svg::parse_svg_document(bytes)?;
369            let intrinsic_size = document.intrinsic_size();
370
371            Ok(Self {
372                inner: Arc::new(SvgPainterInner {
373                    document,
374                    intrinsic_size,
375                    cache: Mutex::new(SvgRasterCache::default()),
376                }),
377            })
378        }
379    }
380
381    pub fn id(&self) -> u64 {
382        Arc::as_ptr(&self.inner) as usize as u64
383    }
384
385    pub fn intrinsic_size(&self) -> Size {
386        self.inner.intrinsic_size
387    }
388
389    pub fn rasterize(&self, pixel_size: Size) -> Result<ImageBitmap, SvgPainterError> {
390        #[cfg(not(feature = "svg"))]
391        {
392            let _ = pixel_size;
393            Err(SvgPainterError::SvgFeatureDisabled)
394        }
395        #[cfg(feature = "svg")]
396        {
397            let key = svg_raster_key(pixel_size)?;
398            if let Some(bitmap) = self.cached_bitmap(key)? {
399                return Ok(bitmap);
400            }
401
402            let bitmap = self.rasterize_uncached(key)?;
403            self.cache_bitmap(key, bitmap.clone())?;
404            Ok(bitmap)
405        }
406    }
407
408    #[cfg(feature = "svg")]
409    fn cached_bitmap(&self, key: SvgRasterKey) -> Result<Option<ImageBitmap>, SvgPainterError> {
410        let mut cache = self.lock_cache();
411        Ok(cache.get(key))
412    }
413
414    #[cfg(feature = "svg")]
415    fn cache_bitmap(&self, key: SvgRasterKey, bitmap: ImageBitmap) -> Result<(), SvgPainterError> {
416        let mut cache = self.lock_cache();
417        cache.insert(key, bitmap);
418        Ok(())
419    }
420
421    #[cfg(feature = "svg")]
422    fn lock_cache(&self) -> MutexGuard<'_, SvgRasterCache> {
423        self.inner
424            .cache
425            .lock()
426            .unwrap_or_else(|poisoned| poisoned.into_inner())
427    }
428
429    #[cfg(feature = "svg")]
430    fn rasterize_uncached(&self, key: SvgRasterKey) -> Result<ImageBitmap, SvgPainterError> {
431        (key.width as usize)
432            .checked_mul(key.height as usize)
433            .and_then(|value| value.checked_mul(4))
434            .ok_or(SvgPainterError::RasterDimensionsTooLarge)?;
435
436        let mut pixmap = tiny_skia::Pixmap::new(key.width, key.height).ok_or(
437            SvgPainterError::RasterAllocationFailed {
438                width: key.width,
439                height: key.height,
440            },
441        )?;
442        image_svg::rasterize_svg_document(&self.inner.document, &mut pixmap);
443        let pixels = image_svg::demultiplied_rgba_pixels(&pixmap);
444        Ok(ImageBitmap::from_rgba8(key.width, key.height, pixels)?)
445    }
446}
447
448impl std::fmt::Debug for SvgPainter {
449    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450        f.debug_struct("SvgPainter")
451            .field("id", &self.id())
452            .field("intrinsic_size", &self.intrinsic_size())
453            .finish_non_exhaustive()
454    }
455}
456
457impl PartialEq for SvgPainter {
458    fn eq(&self, other: &Self) -> bool {
459        self.id() == other.id()
460    }
461}
462
463impl Eq for SvgPainter {}
464
465impl Hash for SvgPainter {
466    fn hash<H: Hasher>(&self, state: &mut H) {
467        self.id().hash(state);
468    }
469}
470
471#[cfg(feature = "svg")]
472impl SvgRasterCache {
473    fn get(&mut self, key: SvgRasterKey) -> Option<ImageBitmap> {
474        let position = self.entries.iter().position(|entry| entry.key == key)?;
475        let entry = self.entries.remove(position);
476        let bitmap = entry.bitmap.clone();
477        self.entries.push(entry);
478        Some(bitmap)
479    }
480
481    fn insert(&mut self, key: SvgRasterKey, bitmap: ImageBitmap) {
482        if let Some(position) = self.entries.iter().position(|entry| entry.key == key) {
483            self.entries.remove(position);
484        } else if self.entries.len() >= SVG_RASTER_CACHE_LIMIT {
485            self.entries.remove(0);
486        }
487        self.entries.push(SvgRasterEntry { key, bitmap });
488    }
489}
490
491#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
492struct SvgBytesKey {
493    ptr: usize,
494    len: usize,
495}
496
497impl SvgBytesKey {
498    fn new(bytes: &'static [u8]) -> Self {
499        Self {
500            ptr: bytes.as_ptr() as usize,
501            len: bytes.len(),
502        }
503    }
504}
505
506pub fn rememberSvg(bytes: &'static [u8]) -> Result<SvgPainter, SvgPainterError> {
507    let key = SvgBytesKey::new(bytes);
508    cranpose_core::withCurrentComposer(|composer| {
509        composer.with_key(&key, |composer| {
510            composer
511                .remember(|| SvgPainter::from_bytes(bytes))
512                .with(|result| result.clone())
513        })
514    })
515}
516
517/// Measure policy for Image that preserves aspect ratio when constraints
518/// force the image smaller than its intrinsic size.
519///
520/// Unlike [`LeafMeasurePolicy`] which clamps width and height independently,
521/// this scales both dimensions by the same factor so the image is never
522/// distorted by layout constraints.
523#[derive(Clone, Debug, PartialEq)]
524struct ImageMeasurePolicy {
525    intrinsic_size: Size,
526}
527
528impl MeasurePolicy for ImageMeasurePolicy {
529    fn measure(
530        &self,
531        scope: &dyn MeasureScope,
532        _measurables: &[Box<dyn Measurable>],
533        constraints: Constraints,
534    ) -> MeasureResult {
535        let mut placements = Vec::new();
536        let size = self.measure_into(scope, &[], constraints, &mut placements);
537        MeasureResult::new(size, placements)
538    }
539
540    fn measure_into(
541        &self,
542        _scope: &dyn MeasureScope,
543        _measurables: &[Box<dyn Measurable>],
544        constraints: Constraints,
545        placements: &mut Vec<Placement>,
546    ) -> Size {
547        placements.clear();
548        let iw = self.intrinsic_size.width;
549        let ih = self.intrinsic_size.height;
550
551        if iw <= 0.0 || ih <= 0.0 {
552            let (w, h) = constraints.constrain(0.0, 0.0);
553            return Size {
554                width: w,
555                height: h,
556            };
557        }
558
559        // Clamp each axis to its constraint range.
560        let cw = iw.clamp(constraints.min_width, constraints.max_width);
561        let ch = ih.clamp(constraints.min_height, constraints.max_height);
562
563        // If either axis had to shrink, scale both by the smaller factor
564        // so the aspect ratio is preserved.
565        let scale_x = cw / iw;
566        let scale_y = ch / ih;
567
568        let (width, height) = if scale_x < 1.0 || scale_y < 1.0 {
569            let factor = scale_x.min(scale_y);
570            let w = (iw * factor).clamp(constraints.min_width, constraints.max_width);
571            let h = (ih * factor).clamp(constraints.min_height, constraints.max_height);
572            (w, h)
573        } else {
574            (cw, ch)
575        };
576
577        Size { width, height }
578    }
579
580    fn min_intrinsic_width(&self, _measurables: &[Box<dyn Measurable>], _height: f32) -> f32 {
581        self.intrinsic_size.width
582    }
583
584    fn max_intrinsic_width(&self, _measurables: &[Box<dyn Measurable>], _height: f32) -> f32 {
585        self.intrinsic_size.width
586    }
587
588    fn min_intrinsic_height(&self, _measurables: &[Box<dyn Measurable>], _width: f32) -> f32 {
589        self.intrinsic_size.height
590    }
591
592    fn max_intrinsic_height(&self, _measurables: &[Box<dyn Measurable>], _width: f32) -> f32 {
593        self.intrinsic_size.height
594    }
595}
596
597fn destination_rect(
598    src_size: Size,
599    dst_size: Size,
600    alignment: Alignment,
601    content_scale: ContentScale,
602) -> Rect {
603    let draw_size = content_scale.scaled_size(src_size, dst_size);
604    let allows_overflow = content_scale == ContentScale::Crop;
605    let offset_x = aligned_x_offset(
606        alignment.horizontal,
607        dst_size.width,
608        draw_size.width,
609        allows_overflow,
610    );
611    let offset_y = aligned_y_offset(
612        alignment.vertical,
613        dst_size.height,
614        draw_size.height,
615        allows_overflow,
616    );
617    Rect {
618        x: offset_x,
619        y: offset_y,
620        width: draw_size.width,
621        height: draw_size.height,
622    }
623}
624
625fn aligned_x_offset(
626    alignment: cranpose_ui_layout::HorizontalAlignment,
627    available: f32,
628    child: f32,
629    allows_overflow: bool,
630) -> f32 {
631    if !allows_overflow {
632        return alignment.align(available, child);
633    }
634
635    match alignment {
636        cranpose_ui_layout::HorizontalAlignment::Start => 0.0,
637        cranpose_ui_layout::HorizontalAlignment::CenterHorizontally => (available - child) / 2.0,
638        cranpose_ui_layout::HorizontalAlignment::End => available - child,
639    }
640}
641
642fn aligned_y_offset(
643    alignment: cranpose_ui_layout::VerticalAlignment,
644    available: f32,
645    child: f32,
646    allows_overflow: bool,
647) -> f32 {
648    if !allows_overflow {
649        return alignment.align(available, child);
650    }
651
652    match alignment {
653        cranpose_ui_layout::VerticalAlignment::Top => 0.0,
654        cranpose_ui_layout::VerticalAlignment::CenterVertically => (available - child) / 2.0,
655        cranpose_ui_layout::VerticalAlignment::Bottom => available - child,
656    }
657}
658
659fn map_destination_clip_to_source(
660    src_rect: Rect,
661    dst_rect: Rect,
662    clipped_dst_rect: Rect,
663) -> Option<Rect> {
664    if src_rect.width <= 0.0
665        || src_rect.height <= 0.0
666        || dst_rect.width <= 0.0
667        || dst_rect.height <= 0.0
668        || clipped_dst_rect.width <= 0.0
669        || clipped_dst_rect.height <= 0.0
670    {
671        return None;
672    }
673
674    let scale_x = src_rect.width / dst_rect.width;
675    let scale_y = src_rect.height / dst_rect.height;
676
677    let src_min_x = src_rect.x;
678    let src_min_y = src_rect.y;
679    let src_max_x = src_rect.x + src_rect.width;
680    let src_max_y = src_rect.y + src_rect.height;
681
682    let raw_left = src_rect.x + (clipped_dst_rect.x - dst_rect.x) * scale_x;
683    let raw_top = src_rect.y + (clipped_dst_rect.y - dst_rect.y) * scale_y;
684    let raw_right =
685        src_rect.x + ((clipped_dst_rect.x + clipped_dst_rect.width) - dst_rect.x) * scale_x;
686    let raw_bottom =
687        src_rect.y + ((clipped_dst_rect.y + clipped_dst_rect.height) - dst_rect.y) * scale_y;
688
689    let left = raw_left.clamp(src_min_x, src_max_x);
690    let top = raw_top.clamp(src_min_y, src_max_y);
691    let right = raw_right.clamp(src_min_x, src_max_x);
692    let bottom = raw_bottom.clamp(src_min_y, src_max_y);
693    let width = right - left;
694    let height = bottom - top;
695
696    if width <= 0.0 || height <= 0.0 {
697        None
698    } else {
699        Some(Rect {
700            x: left,
701            y: top,
702            width,
703            height,
704        })
705    }
706}
707
708fn image_destination_clip(
709    src_size: Size,
710    container_size: Size,
711    alignment: Alignment,
712    content_scale: ContentScale,
713) -> Option<(Rect, Rect)> {
714    let dst_rect = destination_rect(src_size, container_size, alignment, content_scale);
715    if dst_rect.width <= 0.0 || dst_rect.height <= 0.0 {
716        return None;
717    }
718
719    let container_rect = Rect::from_size(container_size);
720    let clipped_dst_rect = dst_rect.intersect(container_rect)?;
721    Some((dst_rect, clipped_dst_rect))
722}
723
724fn draw_bitmap_painter(
725    scope: &mut dyn DrawScope,
726    bitmap: ImageBitmap,
727    intrinsic_size: Size,
728    alignment: Alignment,
729    content_scale: ContentScale,
730    alpha: f32,
731    color_filter: Option<ColorFilter>,
732) {
733    let container_size = scope.size();
734    let Some((dst_rect, clipped_dst_rect)) =
735        image_destination_clip(intrinsic_size, container_size, alignment, content_scale)
736    else {
737        return;
738    };
739    let full_src_rect = Rect::from_size(Size::new(bitmap.width() as f32, bitmap.height() as f32));
740    let Some(clipped_src_rect) =
741        map_destination_clip_to_source(full_src_rect, dst_rect, clipped_dst_rect)
742    else {
743        return;
744    };
745    scope.draw_image_src_sampled(
746        bitmap,
747        clipped_src_rect,
748        clipped_dst_rect,
749        alpha,
750        color_filter,
751        ImageSampling::Linear,
752    );
753}
754
755fn draw_bitmap_region_painter(
756    scope: &mut dyn DrawScope,
757    bitmap: ImageBitmap,
758    source: Rect,
759    alignment: Alignment,
760    content_scale: ContentScale,
761    alpha: f32,
762    color_filter: Option<ColorFilter>,
763    sampling: ImageSampling,
764) {
765    let source_size = Size::new(source.width.max(0.0), source.height.max(0.0));
766    let Some((dst_rect, clipped_dst_rect)) =
767        image_destination_clip(source_size, scope.size(), alignment, content_scale)
768    else {
769        return;
770    };
771    let Some(clipped_source) = map_destination_clip_to_source(source, dst_rect, clipped_dst_rect)
772    else {
773        return;
774    };
775    scope.draw_image_src_sampled(
776        bitmap,
777        clipped_source,
778        clipped_dst_rect,
779        alpha,
780        color_filter,
781        sampling,
782    );
783}
784
785/// Draws a list of source-to-destination quads, clipped to the container.
786///
787/// Every fill that is not a plain scale — tiling, nine-patch — reduces to this,
788/// so the clipping rule and the sampling choice are stated once.
789fn draw_patch_quads(
790    scope: &mut dyn DrawScope,
791    bitmap: &ImageBitmap,
792    quads: &[PatchQuad],
793    container: Rect,
794    alpha: f32,
795    color_filter: Option<ColorFilter>,
796    sampling: ImageSampling,
797) {
798    for quad in quads {
799        let Some(clipped_destination) = intersect(quad.destination, container) else {
800            continue;
801        };
802        let Some(clipped_source) =
803            map_destination_clip_to_source(quad.source, quad.destination, clipped_destination)
804        else {
805            continue;
806        };
807        scope.draw_image_src_sampled(
808            bitmap.clone(),
809            clipped_source,
810            clipped_destination,
811            alpha,
812            color_filter,
813            sampling,
814        );
815    }
816}
817
818fn intersect(rect: Rect, bounds: Rect) -> Option<Rect> {
819    let left = rect.x.max(bounds.x);
820    let top = rect.y.max(bounds.y);
821    let right = (rect.x + rect.width).min(bounds.x + bounds.width);
822    let bottom = (rect.y + rect.height).min(bounds.y + bounds.height);
823    if right <= left || bottom <= top {
824        return None;
825    }
826    Some(Rect {
827        x: left,
828        y: top,
829        width: right - left,
830        height: bottom - top,
831    })
832}
833
834/// Draws a tiled painter across the whole container.
835///
836/// Content scale and alignment do not apply: a tiled fill covers its bounds by
837/// definition, and scaling the source would defeat the reason for tiling it.
838fn draw_bitmap_tiled_painter(
839    scope: &mut dyn DrawScope,
840    bitmap: ImageBitmap,
841    source: Rect,
842    alpha: f32,
843    color_filter: Option<ColorFilter>,
844    sampling: ImageSampling,
845) {
846    let container = Rect::from_size(scope.size());
847    let quads = tile_quads(source, container);
848    draw_patch_quads(
849        scope,
850        &bitmap,
851        &quads,
852        container,
853        alpha,
854        color_filter,
855        sampling,
856    );
857}
858
859/// Draws a nine-patch painter across the whole container.
860///
861/// Like tiling, this fills its bounds by construction, so content scale and
862/// alignment have nothing left to decide.
863#[allow(clippy::too_many_arguments)]
864fn draw_nine_patch_painter(
865    scope: &mut dyn DrawScope,
866    bitmap: ImageBitmap,
867    source: Rect,
868    insets: NinePatchInsets,
869    center: PatchFill,
870    edges: PatchFill,
871    alpha: f32,
872    color_filter: Option<ColorFilter>,
873    sampling: ImageSampling,
874) {
875    let container = Rect::from_size(scope.size());
876    let quads = nine_patch_quads(source, container, insets, center, edges);
877    draw_patch_quads(
878        scope,
879        &bitmap,
880        &quads,
881        container,
882        alpha,
883        color_filter,
884        sampling,
885    );
886}
887
888fn draw_svg_painter(
889    scope: &mut dyn DrawScope,
890    svg: SvgPainter,
891    intrinsic_size: Size,
892    alignment: Alignment,
893    content_scale: ContentScale,
894    alpha: f32,
895    color_filter: Option<ColorFilter>,
896) {
897    let container_size = scope.size();
898    let Some((dst_rect, clipped_dst_rect)) =
899        image_destination_clip(intrinsic_size, container_size, alignment, content_scale)
900    else {
901        return;
902    };
903    let density = crate::render_state::current_density();
904    let pixel_size = Size::new(dst_rect.width * density, dst_rect.height * density);
905    let bitmap = match svg.rasterize(pixel_size) {
906        Ok(bitmap) => bitmap,
907        Err(error) => {
908            log::warn!("failed to rasterize SVG painter: {error}");
909            return;
910        }
911    };
912    let full_src_rect = Rect::from_size(Size::new(bitmap.width() as f32, bitmap.height() as f32));
913    let Some(clipped_src_rect) =
914        map_destination_clip_to_source(full_src_rect, dst_rect, clipped_dst_rect)
915    else {
916        return;
917    };
918    scope.draw_image_src_sampled(
919        bitmap,
920        clipped_src_rect,
921        clipped_dst_rect,
922        alpha,
923        color_filter,
924        ImageSampling::Linear,
925    );
926}
927
928#[cfg(feature = "svg")]
929fn svg_raster_key(pixel_size: Size) -> Result<SvgRasterKey, SvgPainterError> {
930    let width = svg_raster_axis(pixel_size.width)?;
931    let height = svg_raster_axis(pixel_size.height)?;
932    width
933        .checked_mul(height)
934        .and_then(|value| value.checked_mul(4))
935        .ok_or(SvgPainterError::RasterDimensionsTooLarge)?;
936    Ok(SvgRasterKey { width, height })
937}
938
939#[cfg(feature = "svg")]
940fn svg_raster_axis(value: f32) -> Result<u32, SvgPainterError> {
941    if !value.is_finite() || value <= 0.0 {
942        return Err(SvgPainterError::InvalidRasterDimensions);
943    }
944
945    let rounded = value.ceil();
946    if rounded > u32::MAX as f32 {
947        return Err(SvgPainterError::RasterDimensionsTooLarge);
948    }
949    Ok(rounded as u32)
950}
951
952#[composable]
953pub fn Image<P>(
954    painter: P,
955    content_description: Option<String>,
956    modifier: Modifier,
957    alignment: Alignment,
958    content_scale: ContentScale,
959    alpha: f32,
960    color_filter: Option<ColorFilter>,
961) -> NodeId
962where
963    P: Into<Painter> + Clone + PartialEq + 'static,
964{
965    let painter = painter.into();
966    // Painter intrinsic units are logical dp. Bitmap painters use 1 source pixel
967    // per dp; SVG painters rasterize at draw size and current density.
968    let intrinsic_dp = painter.intrinsic_size();
969    let draw_alpha = alpha.clamp(0.0, 1.0);
970    let draw_painter = painter.clone();
971
972    let semantics_modifier = if let Some(description) = content_description {
973        Modifier::empty().semantics(move |config| {
974            config.content_description = Some(description.clone());
975        })
976    } else {
977        Modifier::empty()
978    };
979
980    let image_modifier =
981        modifier
982            .then(semantics_modifier)
983            .draw_behind(move |scope: &mut dyn DrawScope| {
984                if draw_alpha <= 0.0 {
985                    return;
986                }
987                let container_size = scope.size();
988                if container_size.width <= 0.0 || container_size.height <= 0.0 {
989                    return;
990                }
991                match &draw_painter.kind {
992                    PainterKind::Bitmap(bitmap) => draw_bitmap_painter(
993                        scope,
994                        bitmap.clone(),
995                        intrinsic_dp,
996                        alignment,
997                        content_scale,
998                        draw_alpha,
999                        color_filter,
1000                    ),
1001                    PainterKind::BitmapRegion {
1002                        bitmap,
1003                        source,
1004                        sampling,
1005                    } => draw_bitmap_region_painter(
1006                        scope,
1007                        bitmap.clone(),
1008                        source.rect(),
1009                        alignment,
1010                        content_scale,
1011                        draw_alpha,
1012                        color_filter,
1013                        *sampling,
1014                    ),
1015                    PainterKind::BitmapTiled {
1016                        bitmap,
1017                        source,
1018                        sampling,
1019                    } => draw_bitmap_tiled_painter(
1020                        scope,
1021                        bitmap.clone(),
1022                        source.rect(),
1023                        draw_alpha,
1024                        color_filter,
1025                        *sampling,
1026                    ),
1027                    PainterKind::NinePatch {
1028                        bitmap,
1029                        source,
1030                        insets,
1031                        center,
1032                        edges,
1033                        sampling,
1034                    } => draw_nine_patch_painter(
1035                        scope,
1036                        bitmap.clone(),
1037                        source.rect(),
1038                        insets.insets(),
1039                        *center,
1040                        *edges,
1041                        draw_alpha,
1042                        color_filter,
1043                        *sampling,
1044                    ),
1045                    PainterKind::Svg(svg) => draw_svg_painter(
1046                        scope,
1047                        svg.clone(),
1048                        intrinsic_dp,
1049                        alignment,
1050                        content_scale,
1051                        draw_alpha,
1052                        color_filter,
1053                    ),
1054                }
1055            });
1056
1057    Layout(
1058        image_modifier,
1059        ImageMeasurePolicy {
1060            intrinsic_size: intrinsic_dp,
1061        },
1062        || {},
1063    )
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068    use super::*;
1069    use crate::layout::core::Alignment;
1070
1071    #[cfg(feature = "svg")]
1072    const RED_RECT_SVG: &[u8] = br##"
1073        <svg xmlns="http://www.w3.org/2000/svg" width="10" height="20" viewBox="0 0 10 20">
1074          <rect x="0" y="0" width="10" height="20" fill="#ff0000"/>
1075        </svg>
1076    "##;
1077
1078    #[cfg(feature = "svg")]
1079    const TRANSPARENT_CENTER_SVG: &[u8] = br##"
1080        <svg xmlns="http://www.w3.org/2000/svg" width="4" height="4" viewBox="0 0 4 4">
1081          <rect x="1" y="1" width="2" height="2" fill="#00ff00"/>
1082        </svg>
1083    "##;
1084
1085    fn sample_bitmap() -> ImageBitmap {
1086        ImageBitmap::from_rgba8(4, 2, vec![255; 4 * 2 * 4]).expect("bitmap")
1087    }
1088
1089    #[test]
1090    fn svg_painter_ids_do_not_use_process_global_counter() {
1091        let source = include_str!("image.rs");
1092        let svg_counter = ["static ", "NEXT_SVG_PAINTER_ID"].concat();
1093
1094        assert!(
1095            !source.contains(&svg_counter),
1096            "SVG painter ids must come from retained painter identity"
1097        );
1098    }
1099
1100    #[cfg(feature = "svg")]
1101    fn cache_test_bitmap(width: u32) -> ImageBitmap {
1102        ImageBitmap::from_rgba8(width, 1, vec![255; width as usize * 4]).expect("bitmap")
1103    }
1104
1105    #[cfg(feature = "svg")]
1106    fn pixel_at(bitmap: &ImageBitmap, x: u32, y: u32) -> [u8; 4] {
1107        let offset = ((y * bitmap.width() + x) * 4) as usize;
1108        let pixels = bitmap.pixels();
1109        [
1110            pixels[offset],
1111            pixels[offset + 1],
1112            pixels[offset + 2],
1113            pixels[offset + 3],
1114        ]
1115    }
1116
1117    #[test]
1118    fn painter_reports_intrinsic_size_and_bitmap() {
1119        let bitmap = sample_bitmap();
1120        let painter = BitmapPainter(bitmap.clone());
1121        assert_eq!(painter.intrinsic_size(), Size::new(4.0, 2.0));
1122        assert_eq!(painter.bitmap(), Some(&bitmap));
1123    }
1124
1125    #[test]
1126    fn bitmap_region_painter_uses_region_as_intrinsic_size() {
1127        let bitmap = sample_bitmap();
1128        let source = Rect {
1129            x: 1.0,
1130            y: 0.0,
1131            width: 2.0,
1132            height: 1.0,
1133        };
1134        let painter = BitmapRegionPainter(bitmap.clone(), source, ImageSampling::Nearest);
1135        assert_eq!(painter.intrinsic_size(), Size::new(2.0, 1.0));
1136        assert_eq!(painter.bitmap(), Some(&bitmap));
1137        assert_eq!(
1138            painter,
1139            Painter::from_bitmap_region(bitmap, source, ImageSampling::Nearest)
1140        );
1141    }
1142
1143    fn atlas_bitmap() -> ImageBitmap {
1144        ImageBitmap::from_rgba8(64, 64, vec![255; 64 * 64 * 4]).expect("bitmap")
1145    }
1146
1147    fn source_rect(x: f32, y: f32, width: f32, height: f32) -> Rect {
1148        Rect {
1149            x,
1150            y,
1151            width,
1152            height,
1153        }
1154    }
1155
1156    fn drawn_images(
1157        size: Size,
1158        draw: impl FnOnce(&mut cranpose_ui_graphics::DrawScopeDefault),
1159    ) -> Vec<(Rect, Rect)> {
1160        let mut scope = cranpose_ui_graphics::DrawScopeDefault::new(size);
1161        draw(&mut scope);
1162        scope
1163            .into_primitives()
1164            .into_iter()
1165            .filter_map(|primitive| match primitive {
1166                cranpose_ui_graphics::DrawPrimitive::Image { src_rect, rect, .. } => {
1167                    Some((src_rect?, rect))
1168                }
1169                _ => None,
1170            })
1171            .collect()
1172    }
1173
1174    #[test]
1175    fn a_tiled_painter_keeps_the_region_as_its_intrinsic_size() {
1176        let bitmap = atlas_bitmap();
1177        let source = source_rect(8.0, 8.0, 16.0, 4.0);
1178        let painter = TiledPainter(bitmap.clone(), source, ImageSampling::Nearest);
1179        assert_eq!(painter.intrinsic_size(), Size::new(16.0, 4.0));
1180        assert_eq!(painter.bitmap(), Some(&bitmap));
1181        assert_eq!(
1182            painter,
1183            Painter::from_bitmap_tiled(bitmap, source, ImageSampling::Nearest)
1184        );
1185    }
1186
1187    #[test]
1188    fn a_tiled_painter_repeats_its_region_at_its_own_size() {
1189        let bitmap = atlas_bitmap();
1190        let source = source_rect(8.0, 8.0, 16.0, 4.0);
1191        let drawn = drawn_images(Size::new(32.0, 8.0), |scope| {
1192            draw_bitmap_tiled_painter(
1193                scope,
1194                bitmap.clone(),
1195                source,
1196                1.0,
1197                None,
1198                ImageSampling::Nearest,
1199            );
1200        });
1201
1202        assert_eq!(drawn.len(), 4, "two columns by two rows");
1203        assert!(
1204            drawn
1205                .iter()
1206                .all(|(src, dst)| src.width == dst.width && src.height == dst.height),
1207            "a tile is drawn at 1:1, never scaled"
1208        );
1209        assert!(
1210            drawn.iter().all(|(src, _)| src.x >= 8.0
1211                && src.y >= 8.0
1212                && src.x + src.width <= 24.0
1213                && src.y + src.height <= 12.0),
1214            "a tile never reads outside the region it was given"
1215        );
1216        assert_eq!(drawn[0].1, source_rect(0.0, 0.0, 16.0, 4.0));
1217        assert_eq!(drawn[3].1, source_rect(16.0, 4.0, 16.0, 4.0));
1218    }
1219
1220    #[test]
1221    fn a_tiled_painter_clips_the_last_row_and_column() {
1222        let bitmap = atlas_bitmap();
1223        let source = source_rect(0.0, 0.0, 10.0, 10.0);
1224        let drawn = drawn_images(Size::new(25.0, 10.0), |scope| {
1225            draw_bitmap_tiled_painter(
1226                scope,
1227                bitmap.clone(),
1228                source,
1229                1.0,
1230                None,
1231                ImageSampling::Nearest,
1232            );
1233        });
1234        assert_eq!(drawn.len(), 3);
1235        let (src, dst) = drawn[2];
1236        assert_eq!(dst, source_rect(20.0, 0.0, 5.0, 10.0));
1237        assert_eq!(src, source_rect(0.0, 0.0, 5.0, 10.0));
1238    }
1239
1240    #[test]
1241    fn a_nine_patch_painter_keeps_the_region_as_its_intrinsic_size() {
1242        let bitmap = atlas_bitmap();
1243        let source = source_rect(0.0, 0.0, 30.0, 30.0);
1244        let painter = NinePatchPainter(
1245            bitmap.clone(),
1246            source,
1247            NinePatchInsets::uniform(10.0),
1248            PatchFill::Stretch,
1249            PatchFill::Stretch,
1250            ImageSampling::Nearest,
1251        );
1252        assert_eq!(painter.intrinsic_size(), Size::new(30.0, 30.0));
1253        assert_eq!(painter.bitmap(), Some(&bitmap));
1254        assert_eq!(
1255            painter,
1256            Painter::from_nine_patch(
1257                bitmap,
1258                source,
1259                NinePatchInsets::uniform(10.0),
1260                PatchFill::Stretch,
1261                PatchFill::Stretch,
1262                ImageSampling::Nearest,
1263            ),
1264            "two painters built from the same numbers are the same painter"
1265        );
1266    }
1267
1268    #[test]
1269    fn a_nine_patch_painter_draws_its_corners_at_their_own_size() {
1270        let bitmap = atlas_bitmap();
1271        let drawn = drawn_images(Size::new(100.0, 60.0), |scope| {
1272            draw_nine_patch_painter(
1273                scope,
1274                bitmap.clone(),
1275                source_rect(0.0, 0.0, 30.0, 30.0),
1276                NinePatchInsets::uniform(10.0),
1277                PatchFill::Stretch,
1278                PatchFill::Stretch,
1279                1.0,
1280                None,
1281                ImageSampling::Nearest,
1282            );
1283        });
1284
1285        assert_eq!(drawn.len(), 9);
1286        assert_eq!(
1287            drawn[0],
1288            (
1289                source_rect(0.0, 0.0, 10.0, 10.0),
1290                source_rect(0.0, 0.0, 10.0, 10.0),
1291            )
1292        );
1293        assert_eq!(
1294            drawn[8],
1295            (
1296                source_rect(20.0, 20.0, 10.0, 10.0),
1297                source_rect(90.0, 50.0, 10.0, 10.0),
1298            )
1299        );
1300        assert_eq!(
1301            drawn[4],
1302            (
1303                source_rect(10.0, 10.0, 10.0, 10.0),
1304                source_rect(10.0, 10.0, 80.0, 40.0),
1305            ),
1306            "the middle grows on both axes"
1307        );
1308    }
1309
1310    #[test]
1311    fn a_nine_patch_painter_reads_only_its_region_of_an_atlas() {
1312        let bitmap = atlas_bitmap();
1313        let drawn = drawn_images(Size::new(80.0, 40.0), |scope| {
1314            draw_nine_patch_painter(
1315                scope,
1316                bitmap.clone(),
1317                source_rect(32.0, 16.0, 24.0, 24.0),
1318                NinePatchInsets::uniform(8.0),
1319                PatchFill::Tile,
1320                PatchFill::Tile,
1321                1.0,
1322                None,
1323                ImageSampling::Nearest,
1324            );
1325        });
1326
1327        assert!(!drawn.is_empty());
1328        assert!(
1329            drawn.iter().all(|(src, _)| src.x >= 32.0
1330                && src.y >= 16.0
1331                && src.x + src.width <= 56.0
1332                && src.y + src.height <= 40.0),
1333            "no patch may read a neighbouring sprite out of the atlas"
1334        );
1335    }
1336
1337    #[test]
1338    #[cfg(feature = "svg")]
1339    fn svg_painter_reports_intrinsic_size() {
1340        let painter = SvgPainter::from_bytes(RED_RECT_SVG).expect("svg painter");
1341        let clone = painter.clone();
1342        assert_eq!(painter.intrinsic_size(), Size::new(10.0, 20.0));
1343        assert_eq!(painter.id(), clone.id());
1344        assert_eq!(Painter::from_svg(painter).bitmap(), None);
1345    }
1346
1347    #[test]
1348    #[cfg(feature = "svg")]
1349    fn svg_painter_rasterizes_requested_dimensions() {
1350        let painter = SvgPainter::from_bytes(RED_RECT_SVG).expect("svg painter");
1351        let bitmap = painter
1352            .rasterize(Size::new(5.0, 10.0))
1353            .expect("rasterized svg");
1354
1355        assert_eq!(bitmap.width(), 5);
1356        assert_eq!(bitmap.height(), 10);
1357        assert_eq!(pixel_at(&bitmap, 2, 5), [255, 0, 0, 255]);
1358    }
1359
1360    #[test]
1361    #[cfg(feature = "svg")]
1362    fn svg_painter_preserves_transparency() {
1363        let painter = SvgPainter::from_bytes(TRANSPARENT_CENTER_SVG).expect("svg painter");
1364        let bitmap = painter
1365            .rasterize(Size::new(4.0, 4.0))
1366            .expect("rasterized svg");
1367
1368        assert_eq!(pixel_at(&bitmap, 0, 0), [0, 0, 0, 0]);
1369        assert_eq!(pixel_at(&bitmap, 2, 2), [0, 255, 0, 255]);
1370    }
1371
1372    #[test]
1373    #[cfg(feature = "svg")]
1374    fn svg_painter_reuses_cached_raster_for_same_size() {
1375        let painter = SvgPainter::from_bytes(RED_RECT_SVG).expect("svg painter");
1376        let first = painter
1377            .rasterize(Size::new(8.0, 8.0))
1378            .expect("first raster");
1379        let second = painter
1380            .rasterize(Size::new(8.0, 8.0))
1381            .expect("second raster");
1382
1383        assert_eq!(first.id(), second.id());
1384    }
1385
1386    #[test]
1387    #[cfg(feature = "svg")]
1388    fn svg_raster_cache_evicts_least_recently_used_entry() {
1389        let mut cache = SvgRasterCache::default();
1390        let keys: Vec<SvgRasterKey> = (0..SVG_RASTER_CACHE_LIMIT)
1391            .map(|index| SvgRasterKey {
1392                width: index as u32 + 1,
1393                height: 1,
1394            })
1395            .collect();
1396
1397        for key in &keys {
1398            cache.insert(*key, cache_test_bitmap(key.width));
1399        }
1400
1401        let recent = cache.get(keys[0]).expect("cached raster");
1402        let new_key = SvgRasterKey {
1403            width: SVG_RASTER_CACHE_LIMIT as u32 + 1,
1404            height: 1,
1405        };
1406        cache.insert(new_key, cache_test_bitmap(new_key.width));
1407
1408        assert!(cache.get(keys[1]).is_none());
1409        assert_eq!(
1410            cache.get(keys[0]).expect("retained raster").id(),
1411            recent.id()
1412        );
1413        assert!(cache.get(new_key).is_some());
1414    }
1415
1416    #[test]
1417    #[cfg(feature = "svg")]
1418    fn svg_painter_rasterizes_distinct_sizes_separately() {
1419        let painter = SvgPainter::from_bytes(RED_RECT_SVG).expect("svg painter");
1420        let small = painter
1421            .rasterize(Size::new(8.0, 8.0))
1422            .expect("small raster");
1423        let large = painter
1424            .rasterize(Size::new(16.0, 16.0))
1425            .expect("large raster");
1426
1427        assert_ne!(small.id(), large.id());
1428        assert_eq!(large.width(), 16);
1429        assert_eq!(large.height(), 16);
1430    }
1431
1432    #[test]
1433    #[cfg(feature = "svg")]
1434    fn svg_painter_cache_recovers_after_poison() {
1435        let painter = SvgPainter::from_bytes(RED_RECT_SVG).expect("svg painter");
1436        let poison_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1437            let _guard = painter
1438                .inner
1439                .cache
1440                .lock()
1441                .unwrap_or_else(|poisoned| poisoned.into_inner());
1442            panic!("poison svg raster cache for recovery test");
1443        }));
1444
1445        assert!(poison_result.is_err());
1446
1447        let bitmap = painter
1448            .rasterize(Size::new(8.0, 8.0))
1449            .expect("svg raster cache should recover after poisoning");
1450        assert_eq!(bitmap.width(), 8);
1451        assert_eq!(bitmap.height(), 8);
1452    }
1453
1454    #[test]
1455    fn svg_draw_density_has_no_outside_context_fallback() {
1456        let source = include_str!("image.rs");
1457        let fallback_call = ["try_current", "_density()", ".unwrap_or"].concat();
1458        assert!(
1459            !source.contains(&fallback_call),
1460            "SVG image drawing must use the active AppContext density"
1461        );
1462    }
1463
1464    #[test]
1465    #[cfg(feature = "svg")]
1466    fn svg_painter_rejects_invalid_bytes() {
1467        let err = SvgPainter::from_bytes(b"not svg").expect_err("invalid svg");
1468        assert!(matches!(err, SvgPainterError::Parse(_)));
1469    }
1470
1471    #[test]
1472    #[cfg(not(feature = "svg"))]
1473    fn svg_painter_reports_disabled_feature_without_resvg() {
1474        let err = SvgPainter::from_bytes(b"not svg").expect_err("svg feature disabled");
1475        assert!(matches!(err, SvgPainterError::SvgFeatureDisabled));
1476    }
1477
1478    #[test]
1479    fn fit_keeps_aspect_ratio() {
1480        let src = Size::new(200.0, 100.0);
1481        let dst = Size::new(300.0, 300.0);
1482        let result = ContentScale::Fit.scaled_size(src, dst);
1483        assert_eq!(result, Size::new(300.0, 150.0));
1484    }
1485
1486    #[test]
1487    fn crop_fills_bounds() {
1488        let src = Size::new(200.0, 100.0);
1489        let dst = Size::new(300.0, 300.0);
1490        let result = ContentScale::Crop.scaled_size(src, dst);
1491        assert_eq!(result, Size::new(600.0, 300.0));
1492    }
1493
1494    #[test]
1495    fn destination_rect_aligns_center() {
1496        let src = Size::new(200.0, 100.0);
1497        let dst = Size::new(300.0, 300.0);
1498        let rect = destination_rect(src, dst, Alignment::CENTER, ContentScale::Fit);
1499        assert_eq!(
1500            rect,
1501            Rect {
1502                x: 0.0,
1503                y: 75.0,
1504                width: 300.0,
1505                height: 150.0,
1506            }
1507        );
1508    }
1509
1510    #[test]
1511    fn crop_destination_clip_maps_centered_wide_source() {
1512        let src = Size::new(200.0, 100.0);
1513        let dst = Size::new(100.0, 100.0);
1514        let (dst_rect, clipped_dst_rect) =
1515            image_destination_clip(src, dst, Alignment::CENTER, ContentScale::Crop)
1516                .expect("destination clip");
1517        let rect = map_destination_clip_to_source(Rect::from_size(src), dst_rect, clipped_dst_rect)
1518            .expect("source clip");
1519        assert_eq!(
1520            rect,
1521            Rect {
1522                x: 50.0,
1523                y: 0.0,
1524                width: 100.0,
1525                height: 100.0,
1526            }
1527        );
1528    }
1529
1530    #[test]
1531    fn crop_destination_clip_honors_start_alignment() {
1532        let src = Size::new(200.0, 100.0);
1533        let dst = Size::new(100.0, 100.0);
1534        let (dst_rect, clipped_dst_rect) =
1535            image_destination_clip(src, dst, Alignment::TOP_START, ContentScale::Crop)
1536                .expect("destination clip");
1537        let rect = map_destination_clip_to_source(Rect::from_size(src), dst_rect, clipped_dst_rect)
1538            .expect("source clip");
1539        assert_eq!(
1540            rect,
1541            Rect {
1542                x: 0.0,
1543                y: 0.0,
1544                width: 100.0,
1545                height: 100.0,
1546            }
1547        );
1548    }
1549
1550    fn approx_eq(left: f32, right: f32) {
1551        assert!((left - right).abs() < 1e-4, "left={left}, right={right}");
1552    }
1553
1554    #[test]
1555    fn map_destination_clip_to_source_scales_proportionally() {
1556        let src = Rect {
1557            x: 0.0,
1558            y: 0.0,
1559            width: 100.0,
1560            height: 100.0,
1561        };
1562        let dst = Rect {
1563            x: -50.0,
1564            y: 0.0,
1565            width: 200.0,
1566            height: 100.0,
1567        };
1568        let clipped_dst = Rect {
1569            x: 0.0,
1570            y: 0.0,
1571            width: 100.0,
1572            height: 100.0,
1573        };
1574        let mapped = map_destination_clip_to_source(src, dst, clipped_dst).expect("mapped");
1575        approx_eq(mapped.x, 25.0);
1576        approx_eq(mapped.y, 0.0);
1577        approx_eq(mapped.width, 50.0);
1578        approx_eq(mapped.height, 100.0);
1579    }
1580
1581    #[test]
1582    fn map_destination_clip_to_source_returns_full_source_without_clipping() {
1583        let src = Rect {
1584            x: 0.0,
1585            y: 0.0,
1586            width: 120.0,
1587            height: 80.0,
1588        };
1589        let dst = Rect {
1590            x: 10.0,
1591            y: 5.0,
1592            width: 60.0,
1593            height: 40.0,
1594        };
1595        let mapped = map_destination_clip_to_source(src, dst, dst).expect("mapped");
1596        approx_eq(mapped.x, src.x);
1597        approx_eq(mapped.y, src.y);
1598        approx_eq(mapped.width, src.width);
1599        approx_eq(mapped.height, src.height);
1600    }
1601
1602    // --- ImageMeasurePolicy tests ---
1603
1604    fn measure_image(intrinsic: Size, constraints: Constraints) -> Size {
1605        let policy = ImageMeasurePolicy {
1606            intrinsic_size: intrinsic,
1607        };
1608        let scope = crate::density::DensityMeasureScope::new(crate::density::Density::default());
1609        policy.measure(&scope, &[], constraints).size
1610    }
1611
1612    #[test]
1613    fn image_measure_unconstrained() {
1614        let size = measure_image(
1615            Size::new(800.0, 600.0),
1616            Constraints::loose(f32::INFINITY, f32::INFINITY),
1617        );
1618        assert_eq!(size, Size::new(800.0, 600.0));
1619    }
1620
1621    #[test]
1622    fn image_measure_width_constrained_preserves_aspect_ratio() {
1623        // 800×600 constrained to max_width=400 → should scale to 400×300
1624        let size = measure_image(
1625            Size::new(800.0, 600.0),
1626            Constraints::loose(400.0, f32::INFINITY),
1627        );
1628        assert_eq!(size, Size::new(400.0, 300.0));
1629    }
1630
1631    #[test]
1632    fn image_measure_height_constrained_preserves_aspect_ratio() {
1633        // 800×600 constrained to max_height=300 → should scale to 400×300
1634        let size = measure_image(
1635            Size::new(800.0, 600.0),
1636            Constraints::loose(f32::INFINITY, 300.0),
1637        );
1638        assert_eq!(size, Size::new(400.0, 300.0));
1639    }
1640
1641    #[test]
1642    fn image_measure_both_constrained_uses_smaller_factor() {
1643        // 800×600 constrained to 200×400 → width is the bottleneck (0.25)
1644        // scaled: 200×150
1645        let size = measure_image(Size::new(800.0, 600.0), Constraints::loose(200.0, 400.0));
1646        assert_eq!(size, Size::new(200.0, 150.0));
1647    }
1648
1649    #[test]
1650    fn image_measure_fits_within_constraints() {
1651        // 200×100 in a 400×400 container → stays at intrinsic size
1652        let size = measure_image(Size::new(200.0, 100.0), Constraints::loose(400.0, 400.0));
1653        assert_eq!(size, Size::new(200.0, 100.0));
1654    }
1655
1656    #[test]
1657    fn image_measure_zero_intrinsic() {
1658        let size = measure_image(Size::ZERO, Constraints::loose(400.0, 400.0));
1659        assert_eq!(size, Size::new(0.0, 0.0));
1660    }
1661}