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, 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        _measurables: &[Box<dyn Measurable>],
532        constraints: Constraints,
533    ) -> MeasureResult {
534        let mut placements = Vec::new();
535        let size = self.measure_into(&[], constraints, &mut placements);
536        MeasureResult::new(size, placements)
537    }
538
539    fn measure_into(
540        &self,
541        _measurables: &[Box<dyn Measurable>],
542        constraints: Constraints,
543        placements: &mut Vec<Placement>,
544    ) -> Size {
545        placements.clear();
546        let iw = self.intrinsic_size.width;
547        let ih = self.intrinsic_size.height;
548
549        if iw <= 0.0 || ih <= 0.0 {
550            let (w, h) = constraints.constrain(0.0, 0.0);
551            return Size {
552                width: w,
553                height: h,
554            };
555        }
556
557        // Clamp each axis to its constraint range.
558        let cw = iw.clamp(constraints.min_width, constraints.max_width);
559        let ch = ih.clamp(constraints.min_height, constraints.max_height);
560
561        // If either axis had to shrink, scale both by the smaller factor
562        // so the aspect ratio is preserved.
563        let scale_x = cw / iw;
564        let scale_y = ch / ih;
565
566        let (width, height) = if scale_x < 1.0 || scale_y < 1.0 {
567            let factor = scale_x.min(scale_y);
568            let w = (iw * factor).clamp(constraints.min_width, constraints.max_width);
569            let h = (ih * factor).clamp(constraints.min_height, constraints.max_height);
570            (w, h)
571        } else {
572            (cw, ch)
573        };
574
575        Size { width, height }
576    }
577
578    fn min_intrinsic_width(&self, _measurables: &[Box<dyn Measurable>], _height: f32) -> f32 {
579        self.intrinsic_size.width
580    }
581
582    fn max_intrinsic_width(&self, _measurables: &[Box<dyn Measurable>], _height: f32) -> f32 {
583        self.intrinsic_size.width
584    }
585
586    fn min_intrinsic_height(&self, _measurables: &[Box<dyn Measurable>], _width: f32) -> f32 {
587        self.intrinsic_size.height
588    }
589
590    fn max_intrinsic_height(&self, _measurables: &[Box<dyn Measurable>], _width: f32) -> f32 {
591        self.intrinsic_size.height
592    }
593}
594
595fn destination_rect(
596    src_size: Size,
597    dst_size: Size,
598    alignment: Alignment,
599    content_scale: ContentScale,
600) -> Rect {
601    let draw_size = content_scale.scaled_size(src_size, dst_size);
602    let allows_overflow = content_scale == ContentScale::Crop;
603    let offset_x = aligned_x_offset(
604        alignment.horizontal,
605        dst_size.width,
606        draw_size.width,
607        allows_overflow,
608    );
609    let offset_y = aligned_y_offset(
610        alignment.vertical,
611        dst_size.height,
612        draw_size.height,
613        allows_overflow,
614    );
615    Rect {
616        x: offset_x,
617        y: offset_y,
618        width: draw_size.width,
619        height: draw_size.height,
620    }
621}
622
623fn aligned_x_offset(
624    alignment: cranpose_ui_layout::HorizontalAlignment,
625    available: f32,
626    child: f32,
627    allows_overflow: bool,
628) -> f32 {
629    if !allows_overflow {
630        return alignment.align(available, child);
631    }
632
633    match alignment {
634        cranpose_ui_layout::HorizontalAlignment::Start => 0.0,
635        cranpose_ui_layout::HorizontalAlignment::CenterHorizontally => (available - child) / 2.0,
636        cranpose_ui_layout::HorizontalAlignment::End => available - child,
637    }
638}
639
640fn aligned_y_offset(
641    alignment: cranpose_ui_layout::VerticalAlignment,
642    available: f32,
643    child: f32,
644    allows_overflow: bool,
645) -> f32 {
646    if !allows_overflow {
647        return alignment.align(available, child);
648    }
649
650    match alignment {
651        cranpose_ui_layout::VerticalAlignment::Top => 0.0,
652        cranpose_ui_layout::VerticalAlignment::CenterVertically => (available - child) / 2.0,
653        cranpose_ui_layout::VerticalAlignment::Bottom => available - child,
654    }
655}
656
657fn map_destination_clip_to_source(
658    src_rect: Rect,
659    dst_rect: Rect,
660    clipped_dst_rect: Rect,
661) -> Option<Rect> {
662    if src_rect.width <= 0.0
663        || src_rect.height <= 0.0
664        || dst_rect.width <= 0.0
665        || dst_rect.height <= 0.0
666        || clipped_dst_rect.width <= 0.0
667        || clipped_dst_rect.height <= 0.0
668    {
669        return None;
670    }
671
672    let scale_x = src_rect.width / dst_rect.width;
673    let scale_y = src_rect.height / dst_rect.height;
674
675    let src_min_x = src_rect.x;
676    let src_min_y = src_rect.y;
677    let src_max_x = src_rect.x + src_rect.width;
678    let src_max_y = src_rect.y + src_rect.height;
679
680    let raw_left = src_rect.x + (clipped_dst_rect.x - dst_rect.x) * scale_x;
681    let raw_top = src_rect.y + (clipped_dst_rect.y - dst_rect.y) * scale_y;
682    let raw_right =
683        src_rect.x + ((clipped_dst_rect.x + clipped_dst_rect.width) - dst_rect.x) * scale_x;
684    let raw_bottom =
685        src_rect.y + ((clipped_dst_rect.y + clipped_dst_rect.height) - dst_rect.y) * scale_y;
686
687    let left = raw_left.clamp(src_min_x, src_max_x);
688    let top = raw_top.clamp(src_min_y, src_max_y);
689    let right = raw_right.clamp(src_min_x, src_max_x);
690    let bottom = raw_bottom.clamp(src_min_y, src_max_y);
691    let width = right - left;
692    let height = bottom - top;
693
694    if width <= 0.0 || height <= 0.0 {
695        None
696    } else {
697        Some(Rect {
698            x: left,
699            y: top,
700            width,
701            height,
702        })
703    }
704}
705
706fn image_destination_clip(
707    src_size: Size,
708    container_size: Size,
709    alignment: Alignment,
710    content_scale: ContentScale,
711) -> Option<(Rect, Rect)> {
712    let dst_rect = destination_rect(src_size, container_size, alignment, content_scale);
713    if dst_rect.width <= 0.0 || dst_rect.height <= 0.0 {
714        return None;
715    }
716
717    let container_rect = Rect::from_size(container_size);
718    let clipped_dst_rect = dst_rect.intersect(container_rect)?;
719    Some((dst_rect, clipped_dst_rect))
720}
721
722fn draw_bitmap_painter(
723    scope: &mut dyn DrawScope,
724    bitmap: ImageBitmap,
725    intrinsic_size: Size,
726    alignment: Alignment,
727    content_scale: ContentScale,
728    alpha: f32,
729    color_filter: Option<ColorFilter>,
730) {
731    let container_size = scope.size();
732    let Some((dst_rect, clipped_dst_rect)) =
733        image_destination_clip(intrinsic_size, container_size, alignment, content_scale)
734    else {
735        return;
736    };
737    let full_src_rect = Rect::from_size(Size::new(bitmap.width() as f32, bitmap.height() as f32));
738    let Some(clipped_src_rect) =
739        map_destination_clip_to_source(full_src_rect, dst_rect, clipped_dst_rect)
740    else {
741        return;
742    };
743    scope.draw_image_src_sampled(
744        bitmap,
745        clipped_src_rect,
746        clipped_dst_rect,
747        alpha,
748        color_filter,
749        ImageSampling::Linear,
750    );
751}
752
753fn draw_bitmap_region_painter(
754    scope: &mut dyn DrawScope,
755    bitmap: ImageBitmap,
756    source: Rect,
757    alignment: Alignment,
758    content_scale: ContentScale,
759    alpha: f32,
760    color_filter: Option<ColorFilter>,
761    sampling: ImageSampling,
762) {
763    let source_size = Size::new(source.width.max(0.0), source.height.max(0.0));
764    let Some((dst_rect, clipped_dst_rect)) =
765        image_destination_clip(source_size, scope.size(), alignment, content_scale)
766    else {
767        return;
768    };
769    let Some(clipped_source) = map_destination_clip_to_source(source, dst_rect, clipped_dst_rect)
770    else {
771        return;
772    };
773    scope.draw_image_src_sampled(
774        bitmap,
775        clipped_source,
776        clipped_dst_rect,
777        alpha,
778        color_filter,
779        sampling,
780    );
781}
782
783/// Draws a list of source-to-destination quads, clipped to the container.
784///
785/// Every fill that is not a plain scale — tiling, nine-patch — reduces to this,
786/// so the clipping rule and the sampling choice are stated once.
787fn draw_patch_quads(
788    scope: &mut dyn DrawScope,
789    bitmap: &ImageBitmap,
790    quads: &[PatchQuad],
791    container: Rect,
792    alpha: f32,
793    color_filter: Option<ColorFilter>,
794    sampling: ImageSampling,
795) {
796    for quad in quads {
797        let Some(clipped_destination) = intersect(quad.destination, container) else {
798            continue;
799        };
800        let Some(clipped_source) =
801            map_destination_clip_to_source(quad.source, quad.destination, clipped_destination)
802        else {
803            continue;
804        };
805        scope.draw_image_src_sampled(
806            bitmap.clone(),
807            clipped_source,
808            clipped_destination,
809            alpha,
810            color_filter,
811            sampling,
812        );
813    }
814}
815
816fn intersect(rect: Rect, bounds: Rect) -> Option<Rect> {
817    let left = rect.x.max(bounds.x);
818    let top = rect.y.max(bounds.y);
819    let right = (rect.x + rect.width).min(bounds.x + bounds.width);
820    let bottom = (rect.y + rect.height).min(bounds.y + bounds.height);
821    if right <= left || bottom <= top {
822        return None;
823    }
824    Some(Rect {
825        x: left,
826        y: top,
827        width: right - left,
828        height: bottom - top,
829    })
830}
831
832/// Draws a tiled painter across the whole container.
833///
834/// Content scale and alignment do not apply: a tiled fill covers its bounds by
835/// definition, and scaling the source would defeat the reason for tiling it.
836fn draw_bitmap_tiled_painter(
837    scope: &mut dyn DrawScope,
838    bitmap: ImageBitmap,
839    source: Rect,
840    alpha: f32,
841    color_filter: Option<ColorFilter>,
842    sampling: ImageSampling,
843) {
844    let container = Rect::from_size(scope.size());
845    let quads = tile_quads(source, container);
846    draw_patch_quads(
847        scope,
848        &bitmap,
849        &quads,
850        container,
851        alpha,
852        color_filter,
853        sampling,
854    );
855}
856
857/// Draws a nine-patch painter across the whole container.
858///
859/// Like tiling, this fills its bounds by construction, so content scale and
860/// alignment have nothing left to decide.
861#[allow(clippy::too_many_arguments)]
862fn draw_nine_patch_painter(
863    scope: &mut dyn DrawScope,
864    bitmap: ImageBitmap,
865    source: Rect,
866    insets: NinePatchInsets,
867    center: PatchFill,
868    edges: PatchFill,
869    alpha: f32,
870    color_filter: Option<ColorFilter>,
871    sampling: ImageSampling,
872) {
873    let container = Rect::from_size(scope.size());
874    let quads = nine_patch_quads(source, container, insets, center, edges);
875    draw_patch_quads(
876        scope,
877        &bitmap,
878        &quads,
879        container,
880        alpha,
881        color_filter,
882        sampling,
883    );
884}
885
886fn draw_svg_painter(
887    scope: &mut dyn DrawScope,
888    svg: SvgPainter,
889    intrinsic_size: Size,
890    alignment: Alignment,
891    content_scale: ContentScale,
892    alpha: f32,
893    color_filter: Option<ColorFilter>,
894) {
895    let container_size = scope.size();
896    let Some((dst_rect, clipped_dst_rect)) =
897        image_destination_clip(intrinsic_size, container_size, alignment, content_scale)
898    else {
899        return;
900    };
901    let density = crate::render_state::current_density();
902    let pixel_size = Size::new(dst_rect.width * density, dst_rect.height * density);
903    let bitmap = match svg.rasterize(pixel_size) {
904        Ok(bitmap) => bitmap,
905        Err(error) => {
906            log::warn!("failed to rasterize SVG painter: {error}");
907            return;
908        }
909    };
910    let full_src_rect = Rect::from_size(Size::new(bitmap.width() as f32, bitmap.height() as f32));
911    let Some(clipped_src_rect) =
912        map_destination_clip_to_source(full_src_rect, dst_rect, clipped_dst_rect)
913    else {
914        return;
915    };
916    scope.draw_image_src_sampled(
917        bitmap,
918        clipped_src_rect,
919        clipped_dst_rect,
920        alpha,
921        color_filter,
922        ImageSampling::Linear,
923    );
924}
925
926#[cfg(feature = "svg")]
927fn svg_raster_key(pixel_size: Size) -> Result<SvgRasterKey, SvgPainterError> {
928    let width = svg_raster_axis(pixel_size.width)?;
929    let height = svg_raster_axis(pixel_size.height)?;
930    width
931        .checked_mul(height)
932        .and_then(|value| value.checked_mul(4))
933        .ok_or(SvgPainterError::RasterDimensionsTooLarge)?;
934    Ok(SvgRasterKey { width, height })
935}
936
937#[cfg(feature = "svg")]
938fn svg_raster_axis(value: f32) -> Result<u32, SvgPainterError> {
939    if !value.is_finite() || value <= 0.0 {
940        return Err(SvgPainterError::InvalidRasterDimensions);
941    }
942
943    let rounded = value.ceil();
944    if rounded > u32::MAX as f32 {
945        return Err(SvgPainterError::RasterDimensionsTooLarge);
946    }
947    Ok(rounded as u32)
948}
949
950#[composable]
951pub fn Image<P>(
952    painter: P,
953    content_description: Option<String>,
954    modifier: Modifier,
955    alignment: Alignment,
956    content_scale: ContentScale,
957    alpha: f32,
958    color_filter: Option<ColorFilter>,
959) -> NodeId
960where
961    P: Into<Painter> + Clone + PartialEq + 'static,
962{
963    let painter = painter.into();
964    // Painter intrinsic units are logical dp. Bitmap painters use 1 source pixel
965    // per dp; SVG painters rasterize at draw size and current density.
966    let intrinsic_dp = painter.intrinsic_size();
967    let draw_alpha = alpha.clamp(0.0, 1.0);
968    let draw_painter = painter.clone();
969
970    let semantics_modifier = if let Some(description) = content_description {
971        Modifier::empty().semantics(move |config| {
972            config.content_description = Some(description.clone());
973        })
974    } else {
975        Modifier::empty()
976    };
977
978    let image_modifier =
979        modifier
980            .then(semantics_modifier)
981            .draw_behind(move |scope: &mut dyn DrawScope| {
982                if draw_alpha <= 0.0 {
983                    return;
984                }
985                let container_size = scope.size();
986                if container_size.width <= 0.0 || container_size.height <= 0.0 {
987                    return;
988                }
989                match &draw_painter.kind {
990                    PainterKind::Bitmap(bitmap) => draw_bitmap_painter(
991                        scope,
992                        bitmap.clone(),
993                        intrinsic_dp,
994                        alignment,
995                        content_scale,
996                        draw_alpha,
997                        color_filter,
998                    ),
999                    PainterKind::BitmapRegion {
1000                        bitmap,
1001                        source,
1002                        sampling,
1003                    } => draw_bitmap_region_painter(
1004                        scope,
1005                        bitmap.clone(),
1006                        source.rect(),
1007                        alignment,
1008                        content_scale,
1009                        draw_alpha,
1010                        color_filter,
1011                        *sampling,
1012                    ),
1013                    PainterKind::BitmapTiled {
1014                        bitmap,
1015                        source,
1016                        sampling,
1017                    } => draw_bitmap_tiled_painter(
1018                        scope,
1019                        bitmap.clone(),
1020                        source.rect(),
1021                        draw_alpha,
1022                        color_filter,
1023                        *sampling,
1024                    ),
1025                    PainterKind::NinePatch {
1026                        bitmap,
1027                        source,
1028                        insets,
1029                        center,
1030                        edges,
1031                        sampling,
1032                    } => draw_nine_patch_painter(
1033                        scope,
1034                        bitmap.clone(),
1035                        source.rect(),
1036                        insets.insets(),
1037                        *center,
1038                        *edges,
1039                        draw_alpha,
1040                        color_filter,
1041                        *sampling,
1042                    ),
1043                    PainterKind::Svg(svg) => draw_svg_painter(
1044                        scope,
1045                        svg.clone(),
1046                        intrinsic_dp,
1047                        alignment,
1048                        content_scale,
1049                        draw_alpha,
1050                        color_filter,
1051                    ),
1052                }
1053            });
1054
1055    Layout(
1056        image_modifier,
1057        ImageMeasurePolicy {
1058            intrinsic_size: intrinsic_dp,
1059        },
1060        || {},
1061    )
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067    use crate::layout::core::Alignment;
1068
1069    #[cfg(feature = "svg")]
1070    const RED_RECT_SVG: &[u8] = br##"
1071        <svg xmlns="http://www.w3.org/2000/svg" width="10" height="20" viewBox="0 0 10 20">
1072          <rect x="0" y="0" width="10" height="20" fill="#ff0000"/>
1073        </svg>
1074    "##;
1075
1076    #[cfg(feature = "svg")]
1077    const TRANSPARENT_CENTER_SVG: &[u8] = br##"
1078        <svg xmlns="http://www.w3.org/2000/svg" width="4" height="4" viewBox="0 0 4 4">
1079          <rect x="1" y="1" width="2" height="2" fill="#00ff00"/>
1080        </svg>
1081    "##;
1082
1083    fn sample_bitmap() -> ImageBitmap {
1084        ImageBitmap::from_rgba8(4, 2, vec![255; 4 * 2 * 4]).expect("bitmap")
1085    }
1086
1087    #[test]
1088    fn svg_painter_ids_do_not_use_process_global_counter() {
1089        let source = include_str!("image.rs");
1090        let svg_counter = ["static ", "NEXT_SVG_PAINTER_ID"].concat();
1091
1092        assert!(
1093            !source.contains(&svg_counter),
1094            "SVG painter ids must come from retained painter identity"
1095        );
1096    }
1097
1098    #[cfg(feature = "svg")]
1099    fn cache_test_bitmap(width: u32) -> ImageBitmap {
1100        ImageBitmap::from_rgba8(width, 1, vec![255; width as usize * 4]).expect("bitmap")
1101    }
1102
1103    #[cfg(feature = "svg")]
1104    fn pixel_at(bitmap: &ImageBitmap, x: u32, y: u32) -> [u8; 4] {
1105        let offset = ((y * bitmap.width() + x) * 4) as usize;
1106        let pixels = bitmap.pixels();
1107        [
1108            pixels[offset],
1109            pixels[offset + 1],
1110            pixels[offset + 2],
1111            pixels[offset + 3],
1112        ]
1113    }
1114
1115    #[test]
1116    fn painter_reports_intrinsic_size_and_bitmap() {
1117        let bitmap = sample_bitmap();
1118        let painter = BitmapPainter(bitmap.clone());
1119        assert_eq!(painter.intrinsic_size(), Size::new(4.0, 2.0));
1120        assert_eq!(painter.bitmap(), Some(&bitmap));
1121    }
1122
1123    #[test]
1124    fn bitmap_region_painter_uses_region_as_intrinsic_size() {
1125        let bitmap = sample_bitmap();
1126        let source = Rect {
1127            x: 1.0,
1128            y: 0.0,
1129            width: 2.0,
1130            height: 1.0,
1131        };
1132        let painter = BitmapRegionPainter(bitmap.clone(), source, ImageSampling::Nearest);
1133        assert_eq!(painter.intrinsic_size(), Size::new(2.0, 1.0));
1134        assert_eq!(painter.bitmap(), Some(&bitmap));
1135        assert_eq!(
1136            painter,
1137            Painter::from_bitmap_region(bitmap, source, ImageSampling::Nearest)
1138        );
1139    }
1140
1141    fn atlas_bitmap() -> ImageBitmap {
1142        ImageBitmap::from_rgba8(64, 64, vec![255; 64 * 64 * 4]).expect("bitmap")
1143    }
1144
1145    fn source_rect(x: f32, y: f32, width: f32, height: f32) -> Rect {
1146        Rect {
1147            x,
1148            y,
1149            width,
1150            height,
1151        }
1152    }
1153
1154    fn drawn_images(
1155        size: Size,
1156        draw: impl FnOnce(&mut cranpose_ui_graphics::DrawScopeDefault),
1157    ) -> Vec<(Rect, Rect)> {
1158        let mut scope = cranpose_ui_graphics::DrawScopeDefault::new(size);
1159        draw(&mut scope);
1160        scope
1161            .into_primitives()
1162            .into_iter()
1163            .filter_map(|primitive| match primitive {
1164                cranpose_ui_graphics::DrawPrimitive::Image { src_rect, rect, .. } => {
1165                    Some((src_rect?, rect))
1166                }
1167                _ => None,
1168            })
1169            .collect()
1170    }
1171
1172    #[test]
1173    fn a_tiled_painter_keeps_the_region_as_its_intrinsic_size() {
1174        let bitmap = atlas_bitmap();
1175        let source = source_rect(8.0, 8.0, 16.0, 4.0);
1176        let painter = TiledPainter(bitmap.clone(), source, ImageSampling::Nearest);
1177        assert_eq!(painter.intrinsic_size(), Size::new(16.0, 4.0));
1178        assert_eq!(painter.bitmap(), Some(&bitmap));
1179        assert_eq!(
1180            painter,
1181            Painter::from_bitmap_tiled(bitmap, source, ImageSampling::Nearest)
1182        );
1183    }
1184
1185    #[test]
1186    fn a_tiled_painter_repeats_its_region_at_its_own_size() {
1187        let bitmap = atlas_bitmap();
1188        let source = source_rect(8.0, 8.0, 16.0, 4.0);
1189        let drawn = drawn_images(Size::new(32.0, 8.0), |scope| {
1190            draw_bitmap_tiled_painter(
1191                scope,
1192                bitmap.clone(),
1193                source,
1194                1.0,
1195                None,
1196                ImageSampling::Nearest,
1197            );
1198        });
1199
1200        assert_eq!(drawn.len(), 4, "two columns by two rows");
1201        assert!(
1202            drawn
1203                .iter()
1204                .all(|(src, dst)| src.width == dst.width && src.height == dst.height),
1205            "a tile is drawn at 1:1, never scaled"
1206        );
1207        assert!(
1208            drawn.iter().all(|(src, _)| src.x >= 8.0
1209                && src.y >= 8.0
1210                && src.x + src.width <= 24.0
1211                && src.y + src.height <= 12.0),
1212            "a tile never reads outside the region it was given"
1213        );
1214        assert_eq!(drawn[0].1, source_rect(0.0, 0.0, 16.0, 4.0));
1215        assert_eq!(drawn[3].1, source_rect(16.0, 4.0, 16.0, 4.0));
1216    }
1217
1218    #[test]
1219    fn a_tiled_painter_clips_the_last_row_and_column() {
1220        let bitmap = atlas_bitmap();
1221        let source = source_rect(0.0, 0.0, 10.0, 10.0);
1222        let drawn = drawn_images(Size::new(25.0, 10.0), |scope| {
1223            draw_bitmap_tiled_painter(
1224                scope,
1225                bitmap.clone(),
1226                source,
1227                1.0,
1228                None,
1229                ImageSampling::Nearest,
1230            );
1231        });
1232        assert_eq!(drawn.len(), 3);
1233        let (src, dst) = drawn[2];
1234        assert_eq!(dst, source_rect(20.0, 0.0, 5.0, 10.0));
1235        assert_eq!(src, source_rect(0.0, 0.0, 5.0, 10.0));
1236    }
1237
1238    #[test]
1239    fn a_nine_patch_painter_keeps_the_region_as_its_intrinsic_size() {
1240        let bitmap = atlas_bitmap();
1241        let source = source_rect(0.0, 0.0, 30.0, 30.0);
1242        let painter = NinePatchPainter(
1243            bitmap.clone(),
1244            source,
1245            NinePatchInsets::uniform(10.0),
1246            PatchFill::Stretch,
1247            PatchFill::Stretch,
1248            ImageSampling::Nearest,
1249        );
1250        assert_eq!(painter.intrinsic_size(), Size::new(30.0, 30.0));
1251        assert_eq!(painter.bitmap(), Some(&bitmap));
1252        assert_eq!(
1253            painter,
1254            Painter::from_nine_patch(
1255                bitmap,
1256                source,
1257                NinePatchInsets::uniform(10.0),
1258                PatchFill::Stretch,
1259                PatchFill::Stretch,
1260                ImageSampling::Nearest,
1261            ),
1262            "two painters built from the same numbers are the same painter"
1263        );
1264    }
1265
1266    #[test]
1267    fn a_nine_patch_painter_draws_its_corners_at_their_own_size() {
1268        let bitmap = atlas_bitmap();
1269        let drawn = drawn_images(Size::new(100.0, 60.0), |scope| {
1270            draw_nine_patch_painter(
1271                scope,
1272                bitmap.clone(),
1273                source_rect(0.0, 0.0, 30.0, 30.0),
1274                NinePatchInsets::uniform(10.0),
1275                PatchFill::Stretch,
1276                PatchFill::Stretch,
1277                1.0,
1278                None,
1279                ImageSampling::Nearest,
1280            );
1281        });
1282
1283        assert_eq!(drawn.len(), 9);
1284        assert_eq!(
1285            drawn[0],
1286            (
1287                source_rect(0.0, 0.0, 10.0, 10.0),
1288                source_rect(0.0, 0.0, 10.0, 10.0),
1289            )
1290        );
1291        assert_eq!(
1292            drawn[8],
1293            (
1294                source_rect(20.0, 20.0, 10.0, 10.0),
1295                source_rect(90.0, 50.0, 10.0, 10.0),
1296            )
1297        );
1298        assert_eq!(
1299            drawn[4],
1300            (
1301                source_rect(10.0, 10.0, 10.0, 10.0),
1302                source_rect(10.0, 10.0, 80.0, 40.0),
1303            ),
1304            "the middle grows on both axes"
1305        );
1306    }
1307
1308    #[test]
1309    fn a_nine_patch_painter_reads_only_its_region_of_an_atlas() {
1310        let bitmap = atlas_bitmap();
1311        let drawn = drawn_images(Size::new(80.0, 40.0), |scope| {
1312            draw_nine_patch_painter(
1313                scope,
1314                bitmap.clone(),
1315                source_rect(32.0, 16.0, 24.0, 24.0),
1316                NinePatchInsets::uniform(8.0),
1317                PatchFill::Tile,
1318                PatchFill::Tile,
1319                1.0,
1320                None,
1321                ImageSampling::Nearest,
1322            );
1323        });
1324
1325        assert!(!drawn.is_empty());
1326        assert!(
1327            drawn.iter().all(|(src, _)| src.x >= 32.0
1328                && src.y >= 16.0
1329                && src.x + src.width <= 56.0
1330                && src.y + src.height <= 40.0),
1331            "no patch may read a neighbouring sprite out of the atlas"
1332        );
1333    }
1334
1335    #[test]
1336    #[cfg(feature = "svg")]
1337    fn svg_painter_reports_intrinsic_size() {
1338        let painter = SvgPainter::from_bytes(RED_RECT_SVG).expect("svg painter");
1339        let clone = painter.clone();
1340        assert_eq!(painter.intrinsic_size(), Size::new(10.0, 20.0));
1341        assert_eq!(painter.id(), clone.id());
1342        assert_eq!(Painter::from_svg(painter).bitmap(), None);
1343    }
1344
1345    #[test]
1346    #[cfg(feature = "svg")]
1347    fn svg_painter_rasterizes_requested_dimensions() {
1348        let painter = SvgPainter::from_bytes(RED_RECT_SVG).expect("svg painter");
1349        let bitmap = painter
1350            .rasterize(Size::new(5.0, 10.0))
1351            .expect("rasterized svg");
1352
1353        assert_eq!(bitmap.width(), 5);
1354        assert_eq!(bitmap.height(), 10);
1355        assert_eq!(pixel_at(&bitmap, 2, 5), [255, 0, 0, 255]);
1356    }
1357
1358    #[test]
1359    #[cfg(feature = "svg")]
1360    fn svg_painter_preserves_transparency() {
1361        let painter = SvgPainter::from_bytes(TRANSPARENT_CENTER_SVG).expect("svg painter");
1362        let bitmap = painter
1363            .rasterize(Size::new(4.0, 4.0))
1364            .expect("rasterized svg");
1365
1366        assert_eq!(pixel_at(&bitmap, 0, 0), [0, 0, 0, 0]);
1367        assert_eq!(pixel_at(&bitmap, 2, 2), [0, 255, 0, 255]);
1368    }
1369
1370    #[test]
1371    #[cfg(feature = "svg")]
1372    fn svg_painter_reuses_cached_raster_for_same_size() {
1373        let painter = SvgPainter::from_bytes(RED_RECT_SVG).expect("svg painter");
1374        let first = painter
1375            .rasterize(Size::new(8.0, 8.0))
1376            .expect("first raster");
1377        let second = painter
1378            .rasterize(Size::new(8.0, 8.0))
1379            .expect("second raster");
1380
1381        assert_eq!(first.id(), second.id());
1382    }
1383
1384    #[test]
1385    #[cfg(feature = "svg")]
1386    fn svg_raster_cache_evicts_least_recently_used_entry() {
1387        let mut cache = SvgRasterCache::default();
1388        let keys: Vec<SvgRasterKey> = (0..SVG_RASTER_CACHE_LIMIT)
1389            .map(|index| SvgRasterKey {
1390                width: index as u32 + 1,
1391                height: 1,
1392            })
1393            .collect();
1394
1395        for key in &keys {
1396            cache.insert(*key, cache_test_bitmap(key.width));
1397        }
1398
1399        let recent = cache.get(keys[0]).expect("cached raster");
1400        let new_key = SvgRasterKey {
1401            width: SVG_RASTER_CACHE_LIMIT as u32 + 1,
1402            height: 1,
1403        };
1404        cache.insert(new_key, cache_test_bitmap(new_key.width));
1405
1406        assert!(cache.get(keys[1]).is_none());
1407        assert_eq!(
1408            cache.get(keys[0]).expect("retained raster").id(),
1409            recent.id()
1410        );
1411        assert!(cache.get(new_key).is_some());
1412    }
1413
1414    #[test]
1415    #[cfg(feature = "svg")]
1416    fn svg_painter_rasterizes_distinct_sizes_separately() {
1417        let painter = SvgPainter::from_bytes(RED_RECT_SVG).expect("svg painter");
1418        let small = painter
1419            .rasterize(Size::new(8.0, 8.0))
1420            .expect("small raster");
1421        let large = painter
1422            .rasterize(Size::new(16.0, 16.0))
1423            .expect("large raster");
1424
1425        assert_ne!(small.id(), large.id());
1426        assert_eq!(large.width(), 16);
1427        assert_eq!(large.height(), 16);
1428    }
1429
1430    #[test]
1431    #[cfg(feature = "svg")]
1432    fn svg_painter_cache_recovers_after_poison() {
1433        let painter = SvgPainter::from_bytes(RED_RECT_SVG).expect("svg painter");
1434        let poison_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1435            let _guard = painter
1436                .inner
1437                .cache
1438                .lock()
1439                .unwrap_or_else(|poisoned| poisoned.into_inner());
1440            panic!("poison svg raster cache for recovery test");
1441        }));
1442
1443        assert!(poison_result.is_err());
1444
1445        let bitmap = painter
1446            .rasterize(Size::new(8.0, 8.0))
1447            .expect("svg raster cache should recover after poisoning");
1448        assert_eq!(bitmap.width(), 8);
1449        assert_eq!(bitmap.height(), 8);
1450    }
1451
1452    #[test]
1453    fn svg_draw_density_has_no_outside_context_fallback() {
1454        let source = include_str!("image.rs");
1455        let fallback_call = ["try_current", "_density()", ".unwrap_or"].concat();
1456        assert!(
1457            !source.contains(&fallback_call),
1458            "SVG image drawing must use the active AppContext density"
1459        );
1460    }
1461
1462    #[test]
1463    #[cfg(feature = "svg")]
1464    fn svg_painter_rejects_invalid_bytes() {
1465        let err = SvgPainter::from_bytes(b"not svg").expect_err("invalid svg");
1466        assert!(matches!(err, SvgPainterError::Parse(_)));
1467    }
1468
1469    #[test]
1470    #[cfg(not(feature = "svg"))]
1471    fn svg_painter_reports_disabled_feature_without_resvg() {
1472        let err = SvgPainter::from_bytes(b"not svg").expect_err("svg feature disabled");
1473        assert!(matches!(err, SvgPainterError::SvgFeatureDisabled));
1474    }
1475
1476    #[test]
1477    fn fit_keeps_aspect_ratio() {
1478        let src = Size::new(200.0, 100.0);
1479        let dst = Size::new(300.0, 300.0);
1480        let result = ContentScale::Fit.scaled_size(src, dst);
1481        assert_eq!(result, Size::new(300.0, 150.0));
1482    }
1483
1484    #[test]
1485    fn crop_fills_bounds() {
1486        let src = Size::new(200.0, 100.0);
1487        let dst = Size::new(300.0, 300.0);
1488        let result = ContentScale::Crop.scaled_size(src, dst);
1489        assert_eq!(result, Size::new(600.0, 300.0));
1490    }
1491
1492    #[test]
1493    fn destination_rect_aligns_center() {
1494        let src = Size::new(200.0, 100.0);
1495        let dst = Size::new(300.0, 300.0);
1496        let rect = destination_rect(src, dst, Alignment::CENTER, ContentScale::Fit);
1497        assert_eq!(
1498            rect,
1499            Rect {
1500                x: 0.0,
1501                y: 75.0,
1502                width: 300.0,
1503                height: 150.0,
1504            }
1505        );
1506    }
1507
1508    #[test]
1509    fn crop_destination_clip_maps_centered_wide_source() {
1510        let src = Size::new(200.0, 100.0);
1511        let dst = Size::new(100.0, 100.0);
1512        let (dst_rect, clipped_dst_rect) =
1513            image_destination_clip(src, dst, Alignment::CENTER, ContentScale::Crop)
1514                .expect("destination clip");
1515        let rect = map_destination_clip_to_source(Rect::from_size(src), dst_rect, clipped_dst_rect)
1516            .expect("source clip");
1517        assert_eq!(
1518            rect,
1519            Rect {
1520                x: 50.0,
1521                y: 0.0,
1522                width: 100.0,
1523                height: 100.0,
1524            }
1525        );
1526    }
1527
1528    #[test]
1529    fn crop_destination_clip_honors_start_alignment() {
1530        let src = Size::new(200.0, 100.0);
1531        let dst = Size::new(100.0, 100.0);
1532        let (dst_rect, clipped_dst_rect) =
1533            image_destination_clip(src, dst, Alignment::TOP_START, ContentScale::Crop)
1534                .expect("destination clip");
1535        let rect = map_destination_clip_to_source(Rect::from_size(src), dst_rect, clipped_dst_rect)
1536            .expect("source clip");
1537        assert_eq!(
1538            rect,
1539            Rect {
1540                x: 0.0,
1541                y: 0.0,
1542                width: 100.0,
1543                height: 100.0,
1544            }
1545        );
1546    }
1547
1548    fn approx_eq(left: f32, right: f32) {
1549        assert!((left - right).abs() < 1e-4, "left={left}, right={right}");
1550    }
1551
1552    #[test]
1553    fn map_destination_clip_to_source_scales_proportionally() {
1554        let src = Rect {
1555            x: 0.0,
1556            y: 0.0,
1557            width: 100.0,
1558            height: 100.0,
1559        };
1560        let dst = Rect {
1561            x: -50.0,
1562            y: 0.0,
1563            width: 200.0,
1564            height: 100.0,
1565        };
1566        let clipped_dst = Rect {
1567            x: 0.0,
1568            y: 0.0,
1569            width: 100.0,
1570            height: 100.0,
1571        };
1572        let mapped = map_destination_clip_to_source(src, dst, clipped_dst).expect("mapped");
1573        approx_eq(mapped.x, 25.0);
1574        approx_eq(mapped.y, 0.0);
1575        approx_eq(mapped.width, 50.0);
1576        approx_eq(mapped.height, 100.0);
1577    }
1578
1579    #[test]
1580    fn map_destination_clip_to_source_returns_full_source_without_clipping() {
1581        let src = Rect {
1582            x: 0.0,
1583            y: 0.0,
1584            width: 120.0,
1585            height: 80.0,
1586        };
1587        let dst = Rect {
1588            x: 10.0,
1589            y: 5.0,
1590            width: 60.0,
1591            height: 40.0,
1592        };
1593        let mapped = map_destination_clip_to_source(src, dst, dst).expect("mapped");
1594        approx_eq(mapped.x, src.x);
1595        approx_eq(mapped.y, src.y);
1596        approx_eq(mapped.width, src.width);
1597        approx_eq(mapped.height, src.height);
1598    }
1599
1600    // --- ImageMeasurePolicy tests ---
1601
1602    fn measure_image(intrinsic: Size, constraints: Constraints) -> Size {
1603        let policy = ImageMeasurePolicy {
1604            intrinsic_size: intrinsic,
1605        };
1606        policy.measure(&[], constraints).size
1607    }
1608
1609    #[test]
1610    fn image_measure_unconstrained() {
1611        let size = measure_image(
1612            Size::new(800.0, 600.0),
1613            Constraints::loose(f32::INFINITY, f32::INFINITY),
1614        );
1615        assert_eq!(size, Size::new(800.0, 600.0));
1616    }
1617
1618    #[test]
1619    fn image_measure_width_constrained_preserves_aspect_ratio() {
1620        // 800×600 constrained to max_width=400 → should scale to 400×300
1621        let size = measure_image(
1622            Size::new(800.0, 600.0),
1623            Constraints::loose(400.0, f32::INFINITY),
1624        );
1625        assert_eq!(size, Size::new(400.0, 300.0));
1626    }
1627
1628    #[test]
1629    fn image_measure_height_constrained_preserves_aspect_ratio() {
1630        // 800×600 constrained to max_height=300 → should scale to 400×300
1631        let size = measure_image(
1632            Size::new(800.0, 600.0),
1633            Constraints::loose(f32::INFINITY, 300.0),
1634        );
1635        assert_eq!(size, Size::new(400.0, 300.0));
1636    }
1637
1638    #[test]
1639    fn image_measure_both_constrained_uses_smaller_factor() {
1640        // 800×600 constrained to 200×400 → width is the bottleneck (0.25)
1641        // scaled: 200×150
1642        let size = measure_image(Size::new(800.0, 600.0), Constraints::loose(200.0, 400.0));
1643        assert_eq!(size, Size::new(200.0, 150.0));
1644    }
1645
1646    #[test]
1647    fn image_measure_fits_within_constraints() {
1648        // 200×100 in a 400×400 container → stays at intrinsic size
1649        let size = measure_image(Size::new(200.0, 100.0), Constraints::loose(400.0, 400.0));
1650        assert_eq!(size, Size::new(200.0, 100.0));
1651    }
1652
1653    #[test]
1654    fn image_measure_zero_intrinsic() {
1655        let size = measure_image(Size::ZERO, Constraints::loose(400.0, 400.0));
1656        assert_eq!(size, Size::new(0.0, 0.0));
1657    }
1658}