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