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