Skip to main content

zenlayout/
constraint.rs

1//! Layout constraint computation for resize operations.
2//!
3//! Computes dimensions, crop regions, and padding from a constraint mode,
4//! source dimensions, and target dimensions. Pure geometry — no pixel
5//! operations, no allocations, `no_std` compatible.
6//!
7//! # Example
8//!
9//! ```
10//! use zenlayout::{Constraint, ConstraintMode, Size};
11//!
12//! let layout = Constraint::new(ConstraintMode::FitCrop, 400, 300)
13//!     .compute(1000, 500)
14//!     .unwrap();
15//!
16//! // Source cropped to 4:3 aspect ratio, then resized to 400×300
17//! assert_eq!(layout.resize_to, Size::new(400, 300));
18//! assert!(layout.source_crop.is_some());
19//! ```
20
21use crate::float_math::F64Ext;
22
23/// How to fit a source image into target dimensions.
24///
25/// ```text
26///     Source 4:3, Target 1:1 (square):
27///
28///     Fit           Within         FitCrop       FitPad
29///     ┌───┐         ┌───┐          ┌───┐         ┌─────┐
30///     │   │         │   │          │ █ │         │     │
31///     │   │         │   │          │ █ │         │ ███ │
32///     │   │         │   │(smaller) │ █ │         │     │
33///     └───┘         └───┘          └───┘         └─────┘
34///     exact size    ≤ source       fills+crops    fits+pads
35/// ```
36#[non_exhaustive]
37#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
38pub enum ConstraintMode {
39    /// Scale to exact target dimensions, distorting aspect ratio.
40    Distort,
41
42    /// Scale to fit within target, preserving aspect ratio.
43    /// **Will upscale** small images — use [`Within`](Self::Within) to prevent this.
44    /// Output may be smaller than target on one axis.
45    Fit,
46
47    /// Like [`Fit`](Self::Fit), but **never upscales**.
48    /// Images already smaller than target stay their original size.
49    Within,
50
51    /// Scale to fill target, crop overflow to exact target dimensions.
52    /// Preserves aspect ratio. Upscales or downscales as needed.
53    FitCrop,
54
55    /// Like [`FitCrop`](Self::FitCrop), but never upscales.
56    WithinCrop,
57
58    /// Scale to fit within target, pad to exact target dimensions.
59    /// Preserves aspect ratio. Upscales or downscales as needed.
60    FitPad,
61
62    /// Like [`FitPad`](Self::FitPad), but never upscales.
63    /// When the source fits within the target on both axes, the image passes
64    /// through at its original size (identity — no scaling, no padding, no
65    /// canvas expansion). When the source exceeds the target on either axis,
66    /// it is downscaled to fit inside the target and centered on the canvas.
67    WithinPad,
68
69    /// Like [`WithinPad`](Self::WithinPad), but **always pads to target canvas**.
70    ///
71    /// Never upscales. Downscales to fit if the source exceeds target on either
72    /// axis. The canvas is always the target dimensions, even when the image is
73    /// smaller — the image is centered (or gravity-positioned) on the canvas.
74    ///
75    /// Compare with `WithinPad`, which returns identity (no canvas, no padding)
76    /// when the source fits within the target.
77    PadWithin,
78
79    /// Crop to target aspect ratio without any scaling.
80    AspectCrop,
81}
82
83/// Where to position the image when cropping or padding.
84#[non_exhaustive]
85#[derive(Copy, Clone, Debug, Default, PartialEq)]
86pub enum Gravity {
87    /// Center on both axes.
88    #[default]
89    Center,
90    /// Position by percentage. `(0.0, 0.0)` = top-left, `(1.0, 1.0)` = bottom-right.
91    Percentage(f32, f32),
92}
93
94/// Canvas background color for pad modes.
95///
96/// `Srgb` is for user-facing colors in standard sRGB. `Linear` is for callers
97/// already working in linear light (avoids unnecessary color space round-trips
98/// during resize). Both carry alpha.
99#[non_exhaustive]
100#[derive(Copy, Clone, Debug, Default)]
101pub enum CanvasColor {
102    /// Transparent black `[0, 0, 0, 0]` (premultiplied-alpha convention:
103    /// RGB channels are zero when alpha is zero).
104    #[default]
105    Transparent,
106    /// sRGB color with alpha (8-bit per channel).
107    Srgb { r: u8, g: u8, b: u8, a: u8 },
108    /// Linear RGB color with alpha (unspecified color space).
109    Linear { r: f32, g: f32, b: f32, a: f32 },
110}
111
112impl PartialEq for CanvasColor {
113    fn eq(&self, other: &Self) -> bool {
114        match (self, other) {
115            (Self::Transparent, Self::Transparent) => true,
116            (
117                Self::Srgb {
118                    r: r1,
119                    g: g1,
120                    b: b1,
121                    a: a1,
122                },
123                Self::Srgb {
124                    r: r2,
125                    g: g2,
126                    b: b2,
127                    a: a2,
128                },
129            ) => r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2,
130            (
131                Self::Linear {
132                    r: r1,
133                    g: g1,
134                    b: b1,
135                    a: a1,
136                },
137                Self::Linear {
138                    r: r2,
139                    g: g2,
140                    b: b2,
141                    a: a2,
142                },
143            ) => {
144                r1.to_bits() == r2.to_bits()
145                    && g1.to_bits() == g2.to_bits()
146                    && b1.to_bits() == b2.to_bits()
147                    && a1.to_bits() == a2.to_bits()
148            }
149            _ => false,
150        }
151    }
152}
153
154impl Eq for CanvasColor {}
155
156impl core::hash::Hash for CanvasColor {
157    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
158        core::mem::discriminant(self).hash(state);
159        match self {
160            Self::Transparent => {}
161            Self::Srgb { r, g, b, a } => {
162                r.hash(state);
163                g.hash(state);
164                b.hash(state);
165                a.hash(state);
166            }
167            Self::Linear { r, g, b, a } => {
168                r.to_bits().hash(state);
169                g.to_bits().hash(state);
170                b.to_bits().hash(state);
171                a.to_bits().hash(state);
172            }
173        }
174    }
175}
176
177impl CanvasColor {
178    /// White, fully opaque.
179    pub const fn white() -> Self {
180        Self::Srgb {
181            r: 255,
182            g: 255,
183            b: 255,
184            a: 255,
185        }
186    }
187
188    /// Black, fully opaque.
189    pub const fn black() -> Self {
190        Self::Srgb {
191            r: 0,
192            g: 0,
193            b: 0,
194            a: 255,
195        }
196    }
197}
198
199/// Region of source image to use before applying the constraint.
200///
201/// Either absolute pixel coordinates or percentages of source dimensions.
202/// The caller resolves this — for JPEG, the decoder can skip IDCT outside
203/// the crop region; for raw pixels, it's sub-buffer addressing.
204///
205/// Pixel coordinates use **origin + size** convention: `(x, y, width, height)`.
206/// This differs from [`Region`](crate::Region) which uses edge coordinates
207/// `(left, top, right, bottom)`.
208#[non_exhaustive]
209#[derive(Copy, Clone, Debug, PartialEq)]
210pub enum SourceCrop {
211    /// Absolute pixel coordinates.
212    Pixels(Rect),
213    /// Percentage of source dimensions. All values in `0.0..=1.0`.
214    ///
215    /// `x=0.1, y=0.1, width=0.8, height=0.8` crops 10% from each edge.
216    Percent {
217        x: f32,
218        y: f32,
219        width: f32,
220        height: f32,
221    },
222}
223
224impl SourceCrop {
225    /// Create a pixel-based crop region.
226    pub fn pixels(x: u32, y: u32, width: u32, height: u32) -> Self {
227        Self::Pixels(Rect {
228            x,
229            y,
230            width,
231            height,
232        })
233    }
234
235    /// Create a percentage-based crop region.
236    ///
237    /// `x` and `y` are the top-left origin (0.0–1.0), `width` and `height`
238    /// are the region size as a fraction of source dimensions.
239    pub fn percent(x: f32, y: f32, width: f32, height: f32) -> Self {
240        Self::Percent {
241            x,
242            y,
243            width,
244            height,
245        }
246    }
247
248    /// Crop equal margins from all edges.
249    ///
250    /// `margin` is the fraction to remove from each side (0.0–0.5).
251    /// `margin_percent(0.1)` removes 10% from each edge, keeping the center 80%.
252    pub fn margin_percent(margin: f32) -> Self {
253        Self::Percent {
254            x: margin,
255            y: margin,
256            width: (1.0 - 2.0 * margin).max(0.0),
257            height: (1.0 - 2.0 * margin).max(0.0),
258        }
259    }
260
261    /// Crop specific margins from each edge (CSS order: top, right, bottom, left).
262    ///
263    /// All values are fractions of source dimensions (0.0–1.0).
264    pub fn margins_percent(top: f32, right: f32, bottom: f32, left: f32) -> Self {
265        Self::Percent {
266            x: left,
267            y: top,
268            width: (1.0 - left - right).max(0.0),
269            height: (1.0 - top - bottom).max(0.0),
270        }
271    }
272
273    /// Resolve to pixel coordinates for a given source size.
274    pub fn resolve(&self, source_w: u32, source_h: u32) -> Rect {
275        match *self {
276            Self::Pixels(r) => r.clamp_to(source_w, source_h),
277            Self::Percent {
278                x,
279                y,
280                width,
281                height,
282            } => {
283                let px = (source_w as f64 * x.clamp(0.0, 1.0) as f64).round_() as u32;
284                let py = (source_h as f64 * y.clamp(0.0, 1.0) as f64).round_() as u32;
285                let pw = (source_w as f64 * width.clamp(0.0, 1.0) as f64).round_() as u32;
286                let ph = (source_h as f64 * height.clamp(0.0, 1.0) as f64).round_() as u32;
287                Rect {
288                    x: px,
289                    y: py,
290                    width: pw,
291                    height: ph,
292                }
293                .clamp_to(source_w, source_h)
294            }
295        }
296    }
297}
298
299/// Width × height dimensions in pixels.
300#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
301pub struct Size {
302    /// Width in pixels.
303    pub width: u32,
304    /// Height in pixels.
305    pub height: u32,
306}
307
308impl Size {
309    /// Create a new size.
310    pub const fn new(width: u32, height: u32) -> Self {
311        Self { width, height }
312    }
313}
314
315/// Axis-aligned rectangle in pixel coordinates.
316#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
317pub struct Rect {
318    /// Left edge (pixels from origin).
319    pub x: u32,
320    /// Top edge (pixels from origin).
321    pub y: u32,
322    /// Width in pixels.
323    pub width: u32,
324    /// Height in pixels.
325    pub height: u32,
326}
327
328impl Rect {
329    /// Create a new rect.
330    pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
331        Self {
332            x,
333            y,
334            width,
335            height,
336        }
337    }
338
339    /// Clamp this rect to fit within `(0, 0, max_w, max_h)`.
340    /// Width and height are clamped to at least 1.
341    pub fn clamp_to(self, max_w: u32, max_h: u32) -> Self {
342        let x = self.x.min(max_w.saturating_sub(1));
343        let y = self.y.min(max_h.saturating_sub(1));
344        let w = self.width.min(max_w.saturating_sub(x)).max(1);
345        let h = self.height.min(max_h.saturating_sub(y)).max(1);
346        Self {
347            x,
348            y,
349            width: w,
350            height: h,
351        }
352    }
353
354    /// Whether this rect covers the full source (no actual crop).
355    pub fn is_full(&self, source_w: u32, source_h: u32) -> bool {
356        self.x == 0 && self.y == 0 && self.width == source_w && self.height == source_h
357    }
358}
359
360/// Layout constraint specification.
361///
362/// Describes how to fit a source image into target dimensions,
363/// with optional explicit cropping and canvas padding.
364///
365/// # Example
366///
367/// ```
368/// use zenlayout::{Constraint, ConstraintMode, CanvasColor, Gravity, Size};
369///
370/// let layout = Constraint::new(ConstraintMode::FitPad, 400, 300)
371///     .gravity(Gravity::Center)
372///     .canvas_color(CanvasColor::white())
373///     .compute(1000, 500)
374///     .unwrap();
375///
376/// assert_eq!(layout.canvas, Size::new(400, 300));
377/// assert!(layout.needs_padding());
378/// ```
379#[non_exhaustive]
380#[derive(Clone, Debug, PartialEq)]
381pub struct Constraint {
382    /// How the image is fitted to the target dimensions.
383    pub mode: ConstraintMode,
384    /// Target width (pixels). `None` = unconstrained on this axis.
385    pub width: Option<u32>,
386    /// Target height (pixels). `None` = unconstrained on this axis.
387    pub height: Option<u32>,
388    /// Anchor point for crop and pad operations.
389    pub gravity: Gravity,
390    /// Background fill for padding regions.
391    pub canvas_color: CanvasColor,
392    /// Pre-constraint crop applied to the source image.
393    pub source_crop: Option<SourceCrop>,
394}
395
396impl Constraint {
397    /// Create a constraint with both target dimensions.
398    pub fn new(mode: ConstraintMode, width: u32, height: u32) -> Self {
399        Self {
400            mode,
401            width: Some(width),
402            height: Some(height),
403            gravity: Gravity::Center,
404            canvas_color: CanvasColor::Transparent,
405            source_crop: None,
406        }
407    }
408
409    /// Constrain only width (height derived from source aspect ratio).
410    pub fn width_only(mode: ConstraintMode, width: u32) -> Self {
411        Self {
412            mode,
413            width: Some(width),
414            height: None,
415            gravity: Gravity::Center,
416            canvas_color: CanvasColor::Transparent,
417            source_crop: None,
418        }
419    }
420
421    /// Constrain only height (width derived from source aspect ratio).
422    pub fn height_only(mode: ConstraintMode, height: u32) -> Self {
423        Self {
424            mode,
425            width: None,
426            height: Some(height),
427            gravity: Gravity::Center,
428            canvas_color: CanvasColor::Transparent,
429            source_crop: None,
430        }
431    }
432
433    /// Set gravity for crop/pad positioning.
434    pub fn gravity(mut self, gravity: Gravity) -> Self {
435        self.gravity = gravity;
436        self
437    }
438
439    /// Set canvas background color (for pad modes).
440    pub fn canvas_color(mut self, color: CanvasColor) -> Self {
441        self.canvas_color = color;
442        self
443    }
444
445    /// Set explicit source crop (pixel or percentage).
446    ///
447    /// Applied before the constraint mode. When used inside a
448    /// [`Pipeline`](crate::Pipeline), this crop composes with (nests inside)
449    /// any pipeline-level crop or region — it does not replace it.
450    ///
451    /// For JPEG decode pipelines, the caller can pass the resolved crop
452    /// to the decoder to skip IDCT outside the region.
453    pub fn source_crop(mut self, crop: SourceCrop) -> Self {
454        self.source_crop = Some(crop);
455        self
456    }
457
458    /// Check all float parameters for NaN/Inf.
459    fn validate_floats(&self) -> Result<(), LayoutError> {
460        if let Gravity::Percentage(x, y) = self.gravity
461            && (!x.is_finite() || !y.is_finite())
462        {
463            return Err(LayoutError::NonFiniteFloat);
464        }
465        if let CanvasColor::Linear { r, g, b, a } = self.canvas_color
466            && (!r.is_finite() || !g.is_finite() || !b.is_finite() || !a.is_finite())
467        {
468            return Err(LayoutError::NonFiniteFloat);
469        }
470        if let Some(SourceCrop::Percent {
471            x,
472            y,
473            width,
474            height,
475        }) = self.source_crop
476            && (!x.is_finite() || !y.is_finite() || !width.is_finite() || !height.is_finite())
477        {
478            return Err(LayoutError::NonFiniteFloat);
479        }
480        Ok(())
481    }
482
483    /// Compute the layout for a source image of the given dimensions.
484    pub fn compute(&self, source_w: u32, source_h: u32) -> Result<Layout, LayoutError> {
485        if source_w == 0 || source_h == 0 {
486            return Err(LayoutError::ZeroSourceDimension);
487        }
488
489        // Validate float parameters.
490        self.validate_floats()?;
491
492        // Step 1: Apply explicit source crop.
493        let (user_crop, sw, sh) = match &self.source_crop {
494            Some(crop) => {
495                let r = crop.resolve(source_w, source_h);
496                (Some(r), r.width, r.height)
497            }
498            None => (None, source_w, source_h),
499        };
500
501        // Step 2: Resolve target dimensions (fill in missing axis from aspect ratio).
502        let (tw, th) = self.resolve_target(sw, sh)?;
503
504        // Step 2b: Single-axis shortcut.
505        // When only one dimension is specified, the derived dimension already
506        // preserves aspect ratio. Calling fit_inside or crop_to_aspect with
507        // the rounded derived dimension can cause cascading rounding errors
508        // (the wrong axis constrains). All modes degenerate: no crop/pad
509        // is needed because source and target aspect ratios match (within
510        // rounding).
511        use ConstraintMode::*;
512        let single_axis = self.width.is_none() || self.height.is_none();
513        if single_axis {
514            let no_upscale = matches!(self.mode, Within | WithinCrop | WithinPad | PadWithin);
515            let (rw, rh) = if self.mode == AspectCrop {
516                // AspectCrop = crop only, no scaling. Single-axis means
517                // source and target aspect ratios match → no crop → use source.
518                (sw, sh)
519            } else if no_upscale && sw <= tw && sh <= th {
520                (sw, sh)
521            } else {
522                (tw, th)
523            };
524            let (canvas, placement) = match self.mode {
525                FitPad | WithinPad | PadWithin => {
526                    let (px, py) = gravity_offset(tw, th, rw, rh, &self.gravity);
527                    ((tw, th), (px, py))
528                }
529                _ => ((rw, rh), (0, 0)),
530            };
531            return Ok(Layout {
532                source: Size::new(source_w, source_h),
533                source_crop: user_crop,
534                resize_to: Size::new(rw, rh),
535                canvas: Size::new(canvas.0, canvas.1),
536                placement,
537                canvas_color: self.canvas_color,
538            }
539            .normalize());
540        }
541
542        // Step 3: Compute layout based on mode.
543        let layout = match self.mode {
544            Distort => Layout {
545                source: Size::new(source_w, source_h),
546                source_crop: user_crop,
547                resize_to: Size::new(tw, th),
548                canvas: Size::new(tw, th),
549                placement: (0, 0),
550                canvas_color: self.canvas_color,
551            },
552
553            Fit => {
554                let (rw, rh) = fit_inside(sw, sh, tw, th);
555                Layout {
556                    source: Size::new(source_w, source_h),
557                    source_crop: user_crop,
558                    resize_to: Size::new(rw, rh),
559                    canvas: Size::new(rw, rh),
560                    placement: (0, 0),
561                    canvas_color: self.canvas_color,
562                }
563            }
564
565            Within => {
566                let (rw, rh) = if sw <= tw && sh <= th {
567                    (sw, sh)
568                } else {
569                    fit_inside(sw, sh, tw, th)
570                };
571                Layout {
572                    source: Size::new(source_w, source_h),
573                    source_crop: user_crop,
574                    resize_to: Size::new(rw, rh),
575                    canvas: Size::new(rw, rh),
576                    placement: (0, 0),
577                    canvas_color: self.canvas_color,
578                }
579            }
580
581            FitCrop => {
582                let aspect_crop = crop_to_aspect(sw, sh, tw, th, &self.gravity);
583                let combined = combine_crops(user_crop, aspect_crop);
584                Layout {
585                    source: Size::new(source_w, source_h),
586                    source_crop: Some(combined),
587                    resize_to: Size::new(tw, th),
588                    canvas: Size::new(tw, th),
589                    placement: (0, 0),
590                    canvas_color: self.canvas_color,
591                }
592            }
593
594            WithinCrop => {
595                // Matches imageflow's Crop+DownscaleOnly:
596                // Seq 1: skip_if(Either(Less)) → scale_to_outer, crop
597                // Seq 2: skip_unless(Larger1DSmaller1D) → crop_to_intersection
598                if sw <= tw && sh <= th {
599                    // Source fits within target — no action (identity).
600                    Layout {
601                        source: Size::new(source_w, source_h),
602                        source_crop: user_crop,
603                        resize_to: Size::new(sw, sh),
604                        canvas: Size::new(sw, sh),
605                        placement: (0, 0),
606                        canvas_color: self.canvas_color,
607                    }
608                } else if sw >= tw && sh >= th {
609                    // Source exceeds target on both dims — crop to aspect + downscale.
610                    let aspect_crop = crop_to_aspect(sw, sh, tw, th, &self.gravity);
611                    let combined = combine_crops(user_crop, aspect_crop);
612                    Layout {
613                        source: Size::new(source_w, source_h),
614                        source_crop: Some(combined),
615                        resize_to: Size::new(tw, th),
616                        canvas: Size::new(tw, th),
617                        placement: (0, 0),
618                        canvas_color: self.canvas_color,
619                    }
620                } else {
621                    // Mixed: one dim larger, one smaller → crop to intersection.
622                    let rw = sw.min(tw);
623                    let rh = sh.min(th);
624                    let crop = if rw < sw || rh < sh {
625                        let x = if rw < sw {
626                            gravity_offset_1d(sw - rw, &self.gravity, true)
627                        } else {
628                            0
629                        };
630                        let y = if rh < sh {
631                            gravity_offset_1d(sh - rh, &self.gravity, false)
632                        } else {
633                            0
634                        };
635                        let r = Rect::new(x, y, rw, rh);
636                        Some(combine_crops(user_crop, r))
637                    } else {
638                        user_crop
639                    };
640                    Layout {
641                        source: Size::new(source_w, source_h),
642                        source_crop: crop,
643                        resize_to: Size::new(rw, rh),
644                        canvas: Size::new(rw, rh),
645                        placement: (0, 0),
646                        canvas_color: self.canvas_color,
647                    }
648                }
649            }
650
651            FitPad => {
652                let (rw, rh) = fit_inside(sw, sh, tw, th);
653                let (px, py) = gravity_offset(tw, th, rw, rh, &self.gravity);
654                Layout {
655                    source: Size::new(source_w, source_h),
656                    source_crop: user_crop,
657                    resize_to: Size::new(rw, rh),
658                    canvas: Size::new(tw, th),
659                    placement: (px, py),
660                    canvas_color: self.canvas_color,
661                }
662            }
663
664            WithinPad => {
665                // Matches imageflow's Pad+DownscaleOnly:
666                // skip_unless(Either(Greater)) → scale_to_inner, pad
667                // When source fits within target on both dims, skip everything
668                // (no resize, no pad — identity).
669                if sw <= tw && sh <= th {
670                    Layout {
671                        source: Size::new(source_w, source_h),
672                        source_crop: user_crop,
673                        resize_to: Size::new(sw, sh),
674                        canvas: Size::new(sw, sh),
675                        placement: (0, 0),
676                        canvas_color: self.canvas_color,
677                    }
678                } else {
679                    let (rw, rh) = fit_inside(sw, sh, tw, th);
680                    let (px, py) = gravity_offset(tw, th, rw, rh, &self.gravity);
681                    Layout {
682                        source: Size::new(source_w, source_h),
683                        source_crop: user_crop,
684                        resize_to: Size::new(rw, rh),
685                        canvas: Size::new(tw, th),
686                        placement: (px, py),
687                        canvas_color: self.canvas_color,
688                    }
689                }
690            }
691
692            PadWithin => {
693                // Like WithinPad, but always pads to target canvas.
694                // Used by UpscaleCanvas scale mode.
695                let (rw, rh) = if sw <= tw && sh <= th {
696                    (sw, sh) // No upscale
697                } else {
698                    fit_inside(sw, sh, tw, th) // Downscale to fit
699                };
700                let (px, py) = gravity_offset(tw, th, rw, rh, &self.gravity);
701                Layout {
702                    source: Size::new(source_w, source_h),
703                    source_crop: user_crop,
704                    resize_to: Size::new(rw, rh),
705                    canvas: Size::new(tw, th),
706                    placement: (px, py),
707                    canvas_color: self.canvas_color,
708                }
709            }
710
711            AspectCrop => {
712                let aspect_crop = crop_to_aspect(sw, sh, tw, th, &self.gravity);
713                let combined = combine_crops(user_crop, aspect_crop);
714                Layout {
715                    source: Size::new(source_w, source_h),
716                    source_crop: Some(combined),
717                    resize_to: Size::new(combined.width, combined.height),
718                    canvas: Size::new(combined.width, combined.height),
719                    placement: (0, 0),
720                    canvas_color: self.canvas_color,
721                }
722            }
723        };
724
725        // Normalize: if source_crop covers the full source, set to None.
726        Ok(layout.normalize())
727    }
728
729    /// Resolve target dimensions, filling in the missing axis from source aspect ratio.
730    ///
731    /// When only one dimension is specified, crop/pad modes become equivalent
732    /// to fit — the derived dimension matches the source aspect ratio exactly.
733    fn resolve_target(&self, sw: u32, sh: u32) -> Result<(u32, u32), LayoutError> {
734        match (self.width, self.height) {
735            (Some(w), Some(h)) if w == 0 || h == 0 => Err(LayoutError::ZeroTargetDimension),
736            (Some(w), Some(h)) => Ok((w, h)),
737            (Some(0), None) => Err(LayoutError::ZeroTargetDimension),
738            (Some(w), None) => {
739                let h = (sh as f64 * w as f64 / sw as f64).round_().max(1.0) as u32;
740                Ok((w, h))
741            }
742            (None, Some(0)) => Err(LayoutError::ZeroTargetDimension),
743            (None, Some(h)) => {
744                let w = (sw as f64 * h as f64 / sh as f64).round_().max(1.0) as u32;
745                Ok((w, h))
746            }
747            (None, None) => Ok((sw, sh)),
748        }
749    }
750}
751
752/// Computed layout from applying a [`Constraint`] to source dimensions.
753///
754/// Contains everything needed to execute the resize:
755/// - Which region of the source to read
756/// - What dimensions to resize to
757/// - Final canvas size and image placement (for padding)
758///
759/// # Layout geometry
760///
761/// ```text
762///     ┌─────────────── canvas ───────────────┐
763///     │                                       │
764///     │    placement ──┐                      │
765///     │    (x offset)  │                      │
766///     │                ▼                      │
767///     │         ┌── resize_to ──┐             │
768///     │         │               │             │
769///     │         │    image      │             │
770///     │         │               │             │
771///     │         └───────────────┘             │
772///     │                                       │
773///     └───────────────────────────────────────┘
774///
775///     source_crop ──► resize_to ──► placed on canvas
776/// ```
777#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
778#[non_exhaustive]
779pub struct Layout {
780    /// Original source dimensions.
781    pub source: Size,
782    /// Region of source to use. `None` = full source.
783    pub source_crop: Option<Rect>,
784    /// Dimensions to resize the (cropped) source to.
785    pub resize_to: Size,
786    /// Final output canvas dimensions (≥ `resize_to`).
787    pub canvas: Size,
788    /// Top-left offset where the resized image sits on the canvas.
789    pub placement: (i32, i32),
790    /// Canvas background color (for padding areas).
791    pub canvas_color: CanvasColor,
792}
793
794impl Layout {
795    /// Whether resampling is needed (dimensions change).
796    pub fn needs_resize(&self) -> bool {
797        let eff = self.effective_source();
798        self.resize_to != eff
799    }
800
801    /// Whether padding is needed (canvas larger than resized image).
802    pub fn needs_padding(&self) -> bool {
803        self.canvas != self.resize_to
804    }
805
806    /// Whether a source crop is applied (excludes full-source no-ops).
807    pub fn needs_crop(&self) -> bool {
808        self.source_crop.is_some()
809    }
810
811    /// Effective source dimensions after crop.
812    pub fn effective_source(&self) -> Size {
813        match &self.source_crop {
814            Some(r) => Size::new(r.width, r.height),
815            None => self.source,
816        }
817    }
818
819    /// Normalize: clear source_crop if it covers the full source.
820    fn normalize(mut self) -> Self {
821        if let Some(r) = &self.source_crop
822            && r.is_full(self.source.width, self.source.height)
823        {
824            self.source_crop = None;
825        }
826        self
827    }
828}
829
830/// Layout computation error.
831#[non_exhaustive]
832#[derive(Copy, Clone, Debug, PartialEq, Eq)]
833pub enum LayoutError {
834    /// Source image has zero width or height.
835    ZeroSourceDimension,
836    /// Target width or height is zero.
837    ZeroTargetDimension,
838    /// Region viewport has zero or negative width or height.
839    ZeroRegionDimension,
840    /// A float parameter contains NaN or infinity.
841    NonFiniteFloat,
842}
843
844impl core::fmt::Display for LayoutError {
845    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
846        match self {
847            Self::ZeroSourceDimension => f.write_str("source image has zero width or height"),
848            Self::ZeroTargetDimension => f.write_str("target width or height is zero"),
849            Self::ZeroRegionDimension => {
850                f.write_str("region viewport has zero or negative width or height")
851            }
852            Self::NonFiniteFloat => {
853                f.write_str("a float parameter contains NaN or infinity")
854            }
855        }
856    }
857}
858
859#[cfg(feature = "std")]
860impl std::error::Error for LayoutError {}
861
862// ============================================================================
863// Internal geometry
864// ============================================================================
865
866/// Compute dimensions that fit inside the target box, preserving aspect ratio.
867/// One dimension matches the target; the other is ≤ target.
868fn fit_inside(sw: u32, sh: u32, tw: u32, th: u32) -> (u32, u32) {
869    let ratio_w = tw as f64 / sw as f64;
870    let ratio_h = th as f64 / sh as f64;
871    if ratio_w <= ratio_h {
872        // Width constrains — compute height.
873        let h = proportional(sw, sh, tw, true, tw, th);
874        (tw, h)
875    } else {
876        // Height constrains — compute width.
877        let w = proportional(sw, sh, th, false, tw, th);
878        (w, th)
879    }
880}
881
882/// Crop source to match target aspect ratio.
883fn crop_to_aspect(sw: u32, sh: u32, tw: u32, th: u32, gravity: &Gravity) -> Rect {
884    // Use cross-multiplication to avoid floating-point comparison for exact matches.
885    let cross_s = sw as u64 * th as u64;
886    let cross_t = sh as u64 * tw as u64;
887    if cross_s == cross_t {
888        return Rect {
889            x: 0,
890            y: 0,
891            width: sw,
892            height: sh,
893        };
894    }
895
896    let target_ratio = tw as f64 / th as f64;
897    let source_ratio = sw as f64 / sh as f64;
898
899    if source_ratio > target_ratio {
900        // Source is wider — crop width, keep full height.
901        // We need the width that gives target aspect ratio at source height.
902        // Using target ratio tw/th: new_w = sh * tw / th
903        let new_w = proportional(tw, th, sh, false, sw, sh);
904        if new_w >= sw {
905            return Rect {
906                x: 0,
907                y: 0,
908                width: sw,
909                height: sh,
910            };
911        }
912        let x = gravity_offset_1d(sw - new_w, gravity, true);
913        Rect {
914            x,
915            y: 0,
916            width: new_w,
917            height: sh,
918        }
919    } else {
920        // Source is taller — crop height, keep full width.
921        // new_h = sw * th / tw
922        let new_h = proportional(tw, th, sw, true, sw, sh);
923        if new_h >= sh {
924            return Rect {
925                x: 0,
926                y: 0,
927                width: sw,
928                height: sh,
929            };
930        }
931        let y = gravity_offset_1d(sh - new_h, gravity, false);
932        Rect {
933            x: 0,
934            y,
935            width: sw,
936            height: new_h,
937        }
938    }
939}
940
941/// Combine an explicit user crop with a constraint-computed crop.
942/// The constraint crop is in post-user-crop coordinates.
943fn combine_crops(user_crop: Option<Rect>, constraint_crop: Rect) -> Rect {
944    match user_crop {
945        None => constraint_crop,
946        Some(uc) => Rect {
947            x: uc.x + constraint_crop.x,
948            y: uc.y + constraint_crop.y,
949            width: constraint_crop
950                .width
951                .min(uc.width.saturating_sub(constraint_crop.x)),
952            height: constraint_crop
953                .height
954                .min(uc.height.saturating_sub(constraint_crop.y)),
955        },
956    }
957}
958
959/// Compute placement offset for a resized image within a canvas.
960fn gravity_offset(cw: u32, ch: u32, iw: u32, ih: u32, gravity: &Gravity) -> (i32, i32) {
961    let x = gravity_offset_1d(cw.saturating_sub(iw), gravity, true);
962    let y = gravity_offset_1d(ch.saturating_sub(ih), gravity, false);
963    (x as i32, y as i32)
964}
965
966fn gravity_offset_1d(space: u32, gravity: &Gravity, horizontal: bool) -> u32 {
967    if space == 0 {
968        return 0;
969    }
970    match gravity {
971        Gravity::Center => space / 2,
972        Gravity::Percentage(x, y) => {
973            let pct = if horizontal { *x } else { *y };
974            (space as f64 * pct.clamp(0.0, 1.0) as f64).round_() as u32
975        }
976    }
977}
978
979/// Compute the free dimension proportionally, with snap-aware rounding.
980///
981/// Ports imageflow_riapi's `AspectRatio::proportional()` rounding logic.
982/// Given a ratio source (`ratio_w`×`ratio_h`), a fixed dimension (`basis`,
983/// `basis_is_width`), and a snap target (`target_w`×`target_h`), compute
984/// the free dimension with rounding-loss-based snapping.
985///
986/// This prevents cascading rounding errors (e.g., 1200×400 → 100×33 producing
987/// 99 instead of 100) by snapping to whichever candidate (source or target
988/// dimension) has less rounding error.
989fn proportional(
990    ratio_w: u32,
991    ratio_h: u32,
992    basis: u32,
993    basis_is_width: bool,
994    target_w: u32,
995    target_h: u32,
996) -> u32 {
997    let ratio = ratio_w as f64 / ratio_h as f64;
998
999    // Compute rounding loss to determine snap tolerance.
1000    let snap_amount = if basis_is_width {
1001        rounding_loss_height(ratio_w, ratio_h, target_h)
1002    } else {
1003        rounding_loss_width(ratio_w, ratio_h, target_w)
1004    };
1005
1006    // snap_a = source dimension on the free axis
1007    let snap_a = if basis_is_width { ratio_h } else { ratio_w };
1008    // snap_b = target dimension on the free axis
1009    let snap_b = if basis_is_width { target_h } else { target_w };
1010
1011    // Compute the proportional value
1012    let float = if basis_is_width {
1013        basis as f64 / ratio
1014    } else {
1015        ratio * basis as f64
1016    };
1017
1018    let delta_a = (float - snap_a as f64).abs();
1019    let delta_b = (float - snap_b as f64).abs();
1020
1021    let v = if delta_a <= snap_amount && delta_a <= delta_b {
1022        snap_a
1023    } else if delta_b <= snap_amount {
1024        snap_b
1025    } else {
1026        float.round_() as u32
1027    };
1028
1029    if v == 0 { 1 } else { v }
1030}
1031
1032/// Rounding loss when target width is used as basis.
1033/// Matches imageflow's `rounding_loss_based_on_target_width`.
1034fn rounding_loss_width(ratio_w: u32, ratio_h: u32, target_width: u32) -> f64 {
1035    let ratio = ratio_w as f64 / ratio_h as f64;
1036    let target_x_to_self_x = target_width as f64 / ratio_w as f64;
1037    let recreate_y = ratio_h as f64 * target_x_to_self_x;
1038    let rounded_y = recreate_y.round_();
1039    let recreate_x_from_rounded_y = rounded_y * ratio;
1040    (target_width as f64 - recreate_x_from_rounded_y).abs()
1041}
1042
1043/// Rounding loss when target height is used as basis.
1044/// Matches imageflow's `rounding_loss_based_on_target_height`.
1045fn rounding_loss_height(ratio_w: u32, ratio_h: u32, target_height: u32) -> f64 {
1046    let ratio = ratio_w as f64 / ratio_h as f64;
1047    let target_y_to_self_y = target_height as f64 / ratio_h as f64;
1048    let recreate_x = ratio_w as f64 * target_y_to_self_y;
1049    let rounded_x = recreate_x.round_();
1050    let recreate_y_from_rounded_x = rounded_x / ratio;
1051    (target_height as f64 - recreate_y_from_rounded_x).abs()
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057
1058    extern crate alloc;
1059    use alloc::format;
1060    use alloc::string::String;
1061    use alloc::vec;
1062    use alloc::vec::Vec;
1063
1064    // ── fit_inside ──────────────────────────────────────────────────────
1065
1066    #[test]
1067    fn fit_inside_landscape_into_landscape() {
1068        // 1000×500 (2:1) into 400×300 (4:3) → width constrains → 400×200
1069        assert_eq!(fit_inside(1000, 500, 400, 300), (400, 200));
1070    }
1071
1072    #[test]
1073    fn fit_inside_portrait_into_landscape() {
1074        // 500×1000 (1:2) into 400×300 → height constrains → 150×300
1075        assert_eq!(fit_inside(500, 1000, 400, 300), (150, 300));
1076    }
1077
1078    #[test]
1079    fn fit_inside_same_aspect() {
1080        // 1000×500 (2:1) into 400×200 (2:1) → exact fit
1081        assert_eq!(fit_inside(1000, 500, 400, 200), (400, 200));
1082    }
1083
1084    #[test]
1085    fn fit_inside_snap_rounding() {
1086        // 1200×400 (3:1) into 100×33 — without snap, width_for(33) = 99.
1087        // fit_inside should produce (100, 33) because height constrains and
1088        // width snaps to target.
1089        assert_eq!(fit_inside(1200, 400, 100, 33), (100, 33));
1090    }
1091
1092    #[test]
1093    fn fit_inside_square() {
1094        assert_eq!(fit_inside(1000, 500, 200, 200), (200, 100));
1095    }
1096
1097    // ── crop_to_aspect ──────────────────────────────────────────────────
1098
1099    #[test]
1100    fn crop_aspect_wider_source() {
1101        // 1000×500 (2:1) to 4:3 → crop width
1102        let r = crop_to_aspect(1000, 500, 400, 300, &Gravity::Center);
1103        // Expected width: 500 * 4/3 = 666.67 → 667
1104        assert_eq!(r.width, 667);
1105        assert_eq!(r.height, 500);
1106        // Centered: (1000-667)/2 = 166.5 → 167 (round)
1107        assert_eq!(r.x, 166);
1108        assert_eq!(r.y, 0);
1109    }
1110
1111    #[test]
1112    fn crop_aspect_taller_source() {
1113        // 500×1000 (1:2) to 4:3 → crop height
1114        let r = crop_to_aspect(500, 1000, 400, 300, &Gravity::Center);
1115        assert_eq!(r.width, 500);
1116        // Expected height: 500 / (4/3) = 375
1117        assert_eq!(r.height, 375);
1118    }
1119
1120    #[test]
1121    fn crop_aspect_same_ratio() {
1122        let r = crop_to_aspect(800, 600, 400, 300, &Gravity::Center);
1123        assert_eq!(
1124            r,
1125            Rect {
1126                x: 0,
1127                y: 0,
1128                width: 800,
1129                height: 600
1130            }
1131        );
1132    }
1133
1134    #[test]
1135    fn crop_aspect_gravity_top_left() {
1136        let r = crop_to_aspect(1000, 500, 400, 300, &Gravity::Percentage(0.0, 0.0));
1137        assert_eq!(r.x, 0);
1138        assert_eq!(r.y, 0);
1139    }
1140
1141    #[test]
1142    fn crop_aspect_gravity_bottom_right() {
1143        let r = crop_to_aspect(1000, 500, 400, 300, &Gravity::Percentage(1.0, 1.0));
1144        assert_eq!(r.x, 1000 - r.width);
1145        assert_eq!(r.y, 0); // Only width was cropped
1146    }
1147
1148    // ── ConstraintMode::Distort ─────────────────────────────────────────
1149
1150    #[test]
1151    fn distort_ignores_aspect() {
1152        let l = Constraint::new(ConstraintMode::Distort, 400, 300)
1153            .compute(1000, 500)
1154            .unwrap();
1155        assert_eq!(l.resize_to, Size::new(400, 300));
1156        assert_eq!(l.canvas, Size::new(400, 300));
1157        assert!(l.source_crop.is_none());
1158    }
1159
1160    // ── ConstraintMode::Fit ─────────────────────────────────────────────
1161
1162    #[test]
1163    fn fit_downscale() {
1164        let l = Constraint::new(ConstraintMode::Fit, 400, 300)
1165            .compute(1000, 500)
1166            .unwrap();
1167        assert_eq!(l.resize_to, Size::new(400, 200));
1168        assert_eq!(l.canvas, Size::new(400, 200));
1169        assert!(!l.needs_padding());
1170    }
1171
1172    #[test]
1173    fn fit_upscale() {
1174        let l = Constraint::new(ConstraintMode::Fit, 400, 300)
1175            .compute(200, 100)
1176            .unwrap();
1177        assert_eq!(l.resize_to, Size::new(400, 200));
1178    }
1179
1180    // ── ConstraintMode::Within ──────────────────────────────────────────
1181
1182    #[test]
1183    fn within_no_upscale() {
1184        let l = Constraint::new(ConstraintMode::Within, 400, 300)
1185            .compute(200, 100)
1186            .unwrap();
1187        // Source fits within target → no resize.
1188        assert_eq!(l.resize_to, Size::new(200, 100));
1189        assert!(!l.needs_resize());
1190    }
1191
1192    #[test]
1193    fn within_downscale() {
1194        let l = Constraint::new(ConstraintMode::Within, 400, 300)
1195            .compute(1000, 500)
1196            .unwrap();
1197        assert_eq!(l.resize_to, Size::new(400, 200));
1198    }
1199
1200    // ── ConstraintMode::FitCrop ─────────────────────────────────────────
1201
1202    #[test]
1203    fn fit_crop_exact_dimensions() {
1204        let l = Constraint::new(ConstraintMode::FitCrop, 400, 300)
1205            .compute(1000, 500)
1206            .unwrap();
1207        assert_eq!(l.resize_to, Size::new(400, 300));
1208        assert_eq!(l.canvas, Size::new(400, 300));
1209        assert!(l.source_crop.is_some());
1210        let crop = l.source_crop.unwrap();
1211        // Source cropped to 4:3 aspect ratio.
1212        assert_eq!(crop.height, 500);
1213        // crop.width ≈ 667
1214        assert!(crop.width > 650 && crop.width < 680);
1215    }
1216
1217    #[test]
1218    fn fit_crop_same_aspect() {
1219        let l = Constraint::new(ConstraintMode::FitCrop, 400, 200)
1220            .compute(1000, 500)
1221            .unwrap();
1222        assert_eq!(l.resize_to, Size::new(400, 200));
1223        // Same aspect ratio → no crop needed (normalized to None).
1224        assert!(!l.needs_crop());
1225        assert!(l.source_crop.is_none());
1226    }
1227
1228    // ── ConstraintMode::WithinCrop ──────────────────────────────────────
1229
1230    #[test]
1231    fn within_crop_no_upscale() {
1232        let l = Constraint::new(ConstraintMode::WithinCrop, 400, 300)
1233            .compute(200, 100)
1234            .unwrap();
1235        // Source fits within target on both dims → identity (imageflow behavior).
1236        assert_eq!(l.resize_to, Size::new(200, 100));
1237        assert_eq!(l.canvas, Size::new(200, 100));
1238        assert!(l.source_crop.is_none());
1239    }
1240
1241    // ── ConstraintMode::FitPad ──────────────────────────────────────────
1242
1243    #[test]
1244    fn fit_pad_adds_padding() {
1245        let l = Constraint::new(ConstraintMode::FitPad, 400, 300)
1246            .canvas_color(CanvasColor::white())
1247            .compute(1000, 500)
1248            .unwrap();
1249        assert_eq!(l.resize_to, Size::new(400, 200));
1250        assert_eq!(l.canvas, Size::new(400, 300));
1251        assert_eq!(l.placement, (0, 50)); // 50px top padding
1252        assert!(l.needs_padding());
1253    }
1254
1255    #[test]
1256    fn fit_pad_no_padding_when_aspect_matches() {
1257        let l = Constraint::new(ConstraintMode::FitPad, 400, 200)
1258            .compute(1000, 500)
1259            .unwrap();
1260        assert_eq!(l.resize_to, Size::new(400, 200));
1261        assert_eq!(l.canvas, Size::new(400, 200));
1262        assert!(!l.needs_padding());
1263    }
1264
1265    // ── ConstraintMode::WithinPad ───────────────────────────────────────
1266
1267    #[test]
1268    fn within_pad_canvas_expand() {
1269        // Source smaller than target → identity, no padding (imageflow behavior).
1270        let l = Constraint::new(ConstraintMode::WithinPad, 400, 300)
1271            .canvas_color(CanvasColor::white())
1272            .compute(200, 100)
1273            .unwrap();
1274        assert_eq!(l.resize_to, Size::new(200, 100));
1275        assert_eq!(l.canvas, Size::new(200, 100));
1276        assert!(!l.needs_resize());
1277        assert!(!l.needs_padding());
1278    }
1279
1280    #[test]
1281    fn within_pad_downscale_and_pad() {
1282        let l = Constraint::new(ConstraintMode::WithinPad, 400, 300)
1283            .compute(1000, 500)
1284            .unwrap();
1285        assert_eq!(l.resize_to, Size::new(400, 200));
1286        assert_eq!(l.canvas, Size::new(400, 300));
1287        assert_eq!(l.placement, (0, 50));
1288    }
1289
1290    // ── ConstraintMode::AspectCrop ──────────────────────────────────────
1291
1292    #[test]
1293    fn aspect_crop_no_scaling() {
1294        let l = Constraint::new(ConstraintMode::AspectCrop, 400, 300)
1295            .compute(1000, 500)
1296            .unwrap();
1297        let crop = l.source_crop.unwrap();
1298        // Crop to 4:3 from a 2:1 source, no scaling.
1299        assert_eq!(l.resize_to, Size::new(crop.width, crop.height));
1300        assert!(!l.needs_resize());
1301    }
1302
1303    // ── Source crop ─────────────────────────────────────────────────────
1304
1305    #[test]
1306    fn source_crop_pixels() {
1307        let l = Constraint::new(ConstraintMode::Fit, 200, 200)
1308            .source_crop(SourceCrop::pixels(100, 100, 500, 500))
1309            .compute(1000, 1000)
1310            .unwrap();
1311        assert_eq!(
1312            l.source_crop,
1313            Some(Rect {
1314                x: 100,
1315                y: 100,
1316                width: 500,
1317                height: 500
1318            })
1319        );
1320        assert_eq!(l.resize_to, Size::new(200, 200));
1321    }
1322
1323    #[test]
1324    fn source_crop_percent() {
1325        let l = Constraint::new(ConstraintMode::Fit, 200, 200)
1326            .source_crop(SourceCrop::percent(0.25, 0.25, 0.5, 0.5))
1327            .compute(1000, 1000)
1328            .unwrap();
1329        assert_eq!(
1330            l.source_crop,
1331            Some(Rect {
1332                x: 250,
1333                y: 250,
1334                width: 500,
1335                height: 500
1336            })
1337        );
1338    }
1339
1340    #[test]
1341    fn source_crop_combined_with_fit_crop() {
1342        // User crops to center 50%, then FitCrop to 4:3.
1343        let l = Constraint::new(ConstraintMode::FitCrop, 400, 300)
1344            .source_crop(SourceCrop::percent(0.25, 0.25, 0.5, 0.5))
1345            .compute(1000, 1000)
1346            .unwrap();
1347        let crop = l.source_crop.unwrap();
1348        // User crop is (250, 250, 500, 500).
1349        // 500×500 → FitCrop to 4:3 → crop height to 375.
1350        assert_eq!(crop.width, 500);
1351        assert_eq!(crop.height, 375);
1352        // Origin offset: user's 250 + aspect crop offset.
1353        assert_eq!(crop.x, 250);
1354        assert!(crop.y > 250);
1355    }
1356
1357    // ── Source crop margins ───────────────────────────────────────────
1358
1359    #[test]
1360    fn margin_percent_symmetric() {
1361        let crop = SourceCrop::margin_percent(0.1);
1362        let r = crop.resolve(1000, 500);
1363        assert_eq!(
1364            r,
1365            Rect {
1366                x: 100,
1367                y: 50,
1368                width: 800,
1369                height: 400
1370            }
1371        );
1372    }
1373
1374    #[test]
1375    fn margins_percent_asymmetric() {
1376        // CSS order: top, right, bottom, left
1377        let crop = SourceCrop::margins_percent(0.1, 0.2, 0.1, 0.2);
1378        let r = crop.resolve(1000, 500);
1379        assert_eq!(
1380            r,
1381            Rect {
1382                x: 200,
1383                y: 50,
1384                width: 600,
1385                height: 400
1386            }
1387        );
1388    }
1389
1390    #[test]
1391    fn rect_is_full() {
1392        assert!(Rect::new(0, 0, 100, 100).is_full(100, 100));
1393        assert!(!Rect::new(1, 0, 99, 100).is_full(100, 100));
1394        assert!(!Rect::new(0, 0, 99, 100).is_full(100, 100));
1395    }
1396
1397    // ── Width-only / height-only ────────────────────────────────────────
1398
1399    #[test]
1400    fn width_only_computes_height() {
1401        let l = Constraint::width_only(ConstraintMode::Fit, 500)
1402            .compute(1000, 600)
1403            .unwrap();
1404        assert_eq!(l.resize_to, Size::new(500, 300));
1405    }
1406
1407    #[test]
1408    fn height_only_computes_width() {
1409        let l = Constraint::height_only(ConstraintMode::Fit, 300)
1410            .compute(1000, 600)
1411            .unwrap();
1412        assert_eq!(l.resize_to, Size::new(500, 300));
1413    }
1414
1415    #[test]
1416    fn width_only_fit_crop_no_crop() {
1417        // With only width specified, aspect ratios match → no crop.
1418        let l = Constraint::width_only(ConstraintMode::FitCrop, 500)
1419            .compute(1000, 600)
1420            .unwrap();
1421        assert!(!l.needs_crop());
1422        assert!(l.source_crop.is_none());
1423    }
1424
1425    // ── Gravity ─────────────────────────────────────────────────────────
1426
1427    #[test]
1428    fn gravity_top_left_pad() {
1429        let l = Constraint::new(ConstraintMode::FitPad, 400, 400)
1430            .gravity(Gravity::Percentage(0.0, 0.0))
1431            .compute(1000, 500)
1432            .unwrap();
1433        assert_eq!(l.placement, (0, 0));
1434    }
1435
1436    #[test]
1437    fn gravity_bottom_right_pad() {
1438        let l = Constraint::new(ConstraintMode::FitPad, 400, 400)
1439            .gravity(Gravity::Percentage(1.0, 1.0))
1440            .compute(1000, 500)
1441            .unwrap();
1442        assert_eq!(l.resize_to, Size::new(400, 200));
1443        assert_eq!(l.placement, (0, 200));
1444    }
1445
1446    // ── Error cases ─────────────────────────────────────────────────────
1447
1448    #[test]
1449    fn zero_source_errors() {
1450        assert_eq!(
1451            Constraint::new(ConstraintMode::Fit, 100, 100).compute(0, 100),
1452            Err(LayoutError::ZeroSourceDimension)
1453        );
1454    }
1455
1456    #[test]
1457    fn zero_target_errors() {
1458        assert_eq!(
1459            Constraint::new(ConstraintMode::Fit, 0, 100).compute(100, 100),
1460            Err(LayoutError::ZeroTargetDimension)
1461        );
1462    }
1463
1464    // ── Rect clamping ───────────────────────────────────────────────────
1465
1466    #[test]
1467    fn rect_clamp_oversized() {
1468        let r = Rect {
1469            x: 900,
1470            y: 900,
1471            width: 500,
1472            height: 500,
1473        };
1474        let c = r.clamp_to(1000, 1000);
1475        assert_eq!(
1476            c,
1477            Rect {
1478                x: 900,
1479                y: 900,
1480                width: 100,
1481                height: 100
1482            }
1483        );
1484    }
1485
1486    #[test]
1487    fn rect_clamp_zero_width() {
1488        let r = Rect {
1489            x: 1000,
1490            y: 0,
1491            width: 0,
1492            height: 100,
1493        };
1494        let c = r.clamp_to(1000, 1000);
1495        assert!(c.width >= 1);
1496        assert!(c.x < 1000);
1497    }
1498
1499    // ── Layout helpers ──────────────────────────────────────────────────
1500
1501    #[test]
1502    fn needs_resize_false_for_identity() {
1503        let l = Constraint::new(ConstraintMode::Within, 1000, 1000)
1504            .compute(500, 300)
1505            .unwrap();
1506        assert!(!l.needs_resize());
1507    }
1508
1509    #[test]
1510    fn needs_padding_true_for_pad() {
1511        let l = Constraint::new(ConstraintMode::FitPad, 400, 400)
1512            .compute(1000, 500)
1513            .unwrap();
1514        assert!(l.needs_padding());
1515    }
1516
1517    // ════════════════════════════════════════════════════════════════════
1518    // Step 1: Rounding regression (1,185 cases from imageflow RIAPI)
1519    // ════════════════════════════════════════════════════════════════════
1520
1521    #[rustfmt::skip]
1522    static SHRINK_WITHIN_TESTS: [(i32, i32, i32, i32); 1185] = [(1399,697, 280, -1),(1399,689, 200, -1),(1399,685, 193, -1),(1399,683, 212, -1),(1399,673, 396, -1),(1399,671, 270, -1),(1399,665, 365, -1),(1399,659, 190, -1),(1399,656, 193, -1),(1399,652, 162, -1),(1399,643, 260, -1),(1399,643, 260, -1),(1399,637, 291, -1),(1399,628, 362, -1),(1399,628, 362, -1),(1399,622, 343, -1),(1399,614, 270, -1),(1399,614, 270, -1),(1399,607, 363, -1),(1399,600, 232, -1),(1399,600, 232, -1),(1399,594, 305, -1),(1399,587, 342, -1),(1399,585, 391, -1),(1399,582, 256, -1),(1399,577, 217, -1),(1399,569, 193, -1),(1399,568, 383, -1),(1399,564, 222, -1),(1399,560, 346, -1),(1399,556, 39, -1),(1399,554, 125, -1),(1399,551, 179, -1),(1399,545, 163, -1),(1399,540, 307, -1),(1399,537, 353, -1),(1399,534, 93, -1),(1399,530, 260, -1),(1399,526, 254, -1),(1399,526, 254, -1),(1399,520, 265, -1),(1399,516, 61, -1),(1399,512, 291, -1),(1399,512, 97, -1),(1399,508, 263, -1),(1399,500, 270, -1),(1399,497, 190, -1),(1399,497, 114, -1),(1399,493, 271, -1),(1399,489, 216, -1),(1399,481, 397, -1),(1399,480, 290, -1),(1399,480, 290, -1),(1399,474, 152, -1),(1399,468, 139, -1),(1399,464, 300, -1),(1399,459, 32, -1),(1399,456, 158, -1),(1399,450, 300, -1),(1399,449, 148, -1),(1399,445, 11, -1),(1399,440, 310, -1),(1399,438, 107, -1),(1399,435, 320, -1),(1399,431, 297, -1),(1399,427, 172, -1),(1399,424, 325, -1),(1399,419, 202, -1),(1399,417, 52, -1),(1399,413, 188, -1),(1399,408, 108, -1),(1399,406, 143, -1),(1399,401, 232, -1),(1399,397, 259, -1),(1399,394, 158, -1),(1399,392, 298, -1),(1399,389, 196, -1),(1399,387, 338, -1),(1399,384, 388, -1),(1399,380, 289, -1),(1399,377, 154, -1),(1399,372, 220, -1),(1399,370, 259, -1),(1399,367, 223, -1),(1399,364, 98, -1),(1399,362, 114, -1),(1399,360, 68, -1),(1399,359, 189, -1),(1399,355, 333, -1),(1399,351, 277, -1),(1399,348, 203, -1),(1399,346, 374, -1),(1399,345, 221, -1),(1399,341, 240, -1),(1399,338, 387, -1),(1399,335, 332, -1),(1399,333, 355, -1),(1399,330, 248, -1),(1399,328, 386, -1),(1399,326, 324, -1),(1399,324, 326, -1),(1399,321, 146, -1),(1399,319, 182, -1),(1399,317, 267, -1),(1399,314, 274, -1),(1399,313, 257, -1),(1399,310, 264, -1),(1399,309, 206, -1),(1399,307, 180, -1),(1399,305, 383, -1),(1399,304, 237, -1),(1399,301, 244, -1),(1399,299, 386, -1),(1399,299, 255, -1),(1399,295, 377, -1),(1399,295, 230, -1),(1399,293, 74, -1),(1399,291, 387, -1),(1399,290, 41, -1),(1399,288, 85, -1),(1399,286, 203, -1),(1399,283, 393, -1),(1399,279, 183, -1),(1399,279, 178, -1),(1399,278, 234, -1),(1399,277, 250, -1),(1399,275, 379, -1),(1399,272, 162, -1),(1399,272, 54, -1),(1399,269, 169, -1),(1399,269, 91, -1),(1399,269, 13, -1),(1399,267, 317, -1),(1399,264, 310, -1),(1399,263, 125, -1),(1399,261, 335, -1),(1399,259, 397, -1),(1399,258, 122, -1),(1399,257, 313, -1),(1399,256, 194, -1),(1399,255, 299, -1),(1399,253, 235, -1),(1399,252, 297, -1),(1399,250, 277, -1),(1399,248, 330, -1),(1399,247, 337, -1),(1399,246, 327, -1),(1399,245, 197, -1),(1399,244, 43, -1),(1399,242, 211, -1),(1399,241, 357, -1),(1399,240, 341, -1),(1399,238, 385, -1),(1399,237, 304, -1),(1399,236, 329, -1),(1399,234, 278, -1),(1399,233, 9, -1),(1399,231, 324, -1),(1399,230, 295, -1),(1399,229, 168, -1),(1399,228, 316, -1),(1399,225, 115, -1),(1399,224, 153, -1),(1399,223, 367, -1),(1399,221, 345, -1),(1399,220, 124, -1),(1399,219, 214, -1),(1399,218, 369, -1),(1399,216, 204, -1),(1399,216, 68, -1),(1399,215, 244, -1),(1399,214, 219, -1),(1399,213, 266, -1),(1399,211, 242, -1),(1399,209, 251, -1),(1399,208, 380, -1),(1399,206, 309, -1),(1399,205, 58, -1),(1399,204, 120, -1),(1399,203, 286, -1),(1399,201, 261, -1),(1399,199, 355, -1),(1399,198, 378, -1),(1399,197, 316, -1),(1399,197, 245, -1),(1399,196, 389, -1),(1399,195, 391, -1),(1399,194, 256, -1),(1399,191, 260, -1),(1399,190, 335, -1),(1399,189, 396, -1),(1399,189, 359, -1),(1399,187, 288, -1),(1399,186, 267, -1),(1399,185, 397, -1),(1399,184, 19, -1),(1399,183, 172, -1),(1399,182, 319, -1),(1399,181, 228, -1),(1399,180, 307, -1),(1399,179, 254, -1),(1399,178, 279, -1),(1399,177, 328, -1),(1399,176, 155, -1),(1399,174, 205, -1),(1399,173, 376, -1),(1399,172, 305, -1),(1399,172, 61, -1),(1399,170, 144, -1),(1399,168, 229, -1),(1399,167, 289, -1),(1399,165, 284, -1),(1399,165, 89, -1),(1399,164, 354, -1),(1399,163, 339, -1),(1399,162, 367, -1),(1399,161, 265, -1),(1399,160, 153, -1),(1399,158, 394, -1),(1399,157, 147, -1),(1399,157, 49, -1),(1399,156, 139, -1),(1399,155, 176, -1),(1399,154, 377, -1),(1399,153, 224, -1),(1399,153, 96, -1),(1399,152, 69, -1),(1399,152, 23, -1),(1399,151, 88, -1),(1399,149, 399, -1),(1399,149, 61, -1),(1399,148, 345, -1),(1399,147, 157, -1),(1399,146, 321, -1),(1399,145, 82, -1),(1399,144, 306, -1),(1399,144, 170, -1),(1399,144, 34, -1),(1399,143, 44, -1),(1399,142, 399, -1),(1399,141, 253, -1),(1399,139, 317, -1),(1399,139, 156, -1),(1399,138, 370, -1),(1399,137, 291, -1),(1399,137, 97, -1),(1399,136, 324, -1),(1399,136, 180, -1),(1399,136, 36, -1),(1399,134, 214, -1),(1399,133, 163, -1),(1399,133, 142, -1),(1399,131, 315, -1),(1399,131, 283, -1),(1399,130, 382, -1),(1399,129, 244, -1),(1399,128, 388, -1),(1399,127, 369, -1),(1399,127, 358, -1),(1399,126, 272, -1),(1399,125, 263, -1),(1399,124, 220, -1),(1399,123, 381, -1),(1399,122, 258, -1),(1399,121, 237, -1),(1399,120, 204, -1),(1399,119, 335, -1),(1399,119, 288, -1),(1399,119, 241, -1),(1399,118, 326, -1),(1399,117, 269, -1),(1399,116, 211, -1),(1399,115, 371, -1),(1399,114, 362, -1),(1399,113, 229, -1),(1399,112, 331, -1),(1399,111, 397, -1),(1399,110, 337, -1),(1399,110, 248, -1),(1399,108, 395, -1),(1399,108, 136, -1),(1399,107, 268, -1),(1399,105, 393, -1),(1399,104, 343, -1),(1399,103, 292, -1),(1399,102, 336, -1),(1399,102, 144, -1),(1399,101, 367, -1),(1399,101, 90, -1),(1399,99, 332, -1),(1399,99, 219, -1),(1399,98, 364, -1),(1399,97, 310, -1),(1399,97, 137, -1),(1399,96, 357, -1),(1399,96, 255, -1),(1399,96, 153, -1),(1399,96, 51, -1),(1399,95, 346, -1),(1399,94, 305, -1),(1399,94, 186, -1),(1399,93, 203, -1),(1399,93, 188, -1),(1399,92, 190, -1),(1399,92, 114, -1),(1399,92, 38, -1),(1399,91, 392, -1),(1399,90, 272, -1),(1399,89, 275, -1),(1399,89, 165, -1),(1399,89, 55, -1),(1399,88, 310, -1),(1399,87, 217, -1),(1399,87, 201, -1),(1399,86, 366, -1),(1399,86, 122, -1),(1399,85, 288, -1),(1399,84, 358, -1),(1399,83, 396, -1),(1399,82, 179, -1),(1399,82, 162, -1),(1399,82, 145, -1),(1399,81, 354, -1),(1399,80, 341, -1),(1399,79, 363, -1),(1399,78, 278, -1),(1399,77, 227, -1),(1399,77, 118, -1),(1399,76, 322, -1),(1399,76, 230, -1),(1399,76, 138, -1),(1399,76, 46, -1),(1399,75, 345, -1),(1399,74, 293, -1),(1399,73, 297, -1),(1399,72, 340, -1),(1399,72, 204, -1),(1399,72, 68, -1),(1399,71, 325, -1),(1399,71, 266, -1),(1399,69, 375, -1),(1399,69, 152, -1),(1399,68, 360, -1),(1399,68, 216, -1),(1399,68, 72, -1),(1399,67, 261, -1),(1399,66, 392, -1),(1399,65, 398, -1),(1399,65, 355, -1),(1399,65, 312, -1),(1399,65, 269, -1),(1399,64, 295, -1),(1399,64, 142, -1),(1399,63, 344, -1),(1399,63, 233, -1),(1399,63, 122, -1),(1399,62, 327, -1),(1399,62, 282, -1),(1399,61, 172, -1),(1399,60, 338, -1),(1399,59, 391, -1),(1399,59, 320, -1),(1399,58, 253, -1),(1399,58, 229, -1),(1399,58, 205, -1),(1399,57, 233, -1),(1399,57, 184, -1),(1399,56, 387, -1),(1399,55, 394, -1),(1399,55, 267, -1),(1399,55, 89, -1),(1399,54, 272, -1),(1399,53, 277, -1),(1399,52, 390, -1),(1399,52, 121, -1),(1399,51, 288, -1),(1399,51, 96, -1),(1399,49, 385, -1),(1399,49, 328, -1),(1399,49, 271, -1),(1399,49, 214, -1),(1399,49, 157, -1),(1399,48, 335, -1),(1399,48, 306, -1),(1399,48, 102, -1),(1399,47, 372, -1),(1399,47, 253, -1),(1399,46, 380, -1),(1399,46, 228, -1),(1399,46, 76, -1),(1399,45, 295, -1),(1399,45, 264, -1),(1399,45, 233, -1),(1399,45, 202, -1),(1399,44, 302, -1),(1399,43, 374, -1),(1399,43, 309, -1),(1399,43, 244, -1),(1399,42, 383, -1),(1399,41, 392, -1),(1399,41, 358, -1),(1399,41, 324, -1),(1399,41, 290, -1),(1399,40, 367, -1),(1399,39, 376, -1),(1399,39, 269, -1),(1399,38, 276, -1),(1399,38, 92, -1),(1399,37, 397, -1),(1399,36, 369, -1),(1399,36, 136, -1),(1399,34, 390, -1),(1399,34, 349, -1),(1399,34, 308, -1),(1399,34, 267, -1),(1399,34, 226, -1),(1399,34, 185, -1),(1399,34, 144, -1),(1399,33, 360, -1),(1399,33, 233, -1),(1399,32, 371, -1),(1399,32, 284, -1),(1399,32, 153, -1),(1399,31, 383, -1),(1399,31, 338, -1),(1399,31, 293, -1),(1399,31, 248, -1),(1399,31, 203, -1),(1399,30, 396, -1),(1399,30, 303, -1),(1399,29, 361, -1),(1399,29, 313, -1),(1399,29, 265, -1),(1399,29, 217, -1),(1399,28, 374, -1),(1399,27, 388, -1),(1399,27, 233, -1),(1399,26, 349, -1),(1399,26, 242, -1),(1399,25, 363, -1),(1399,24, 378, -1),(1399,24, 320, -1),(1399,24, 262, -1),(1399,24, 204, -1),(1399,23, 395, -1),(1399,23, 152, -1),(1399,22, 349, -1),(1399,22, 286, -1),(1399,21, 366, -1),(1399,21, 233, -1),(1399,20, 384, -1),(1399,19, 331, -1),(1399,19, 184, -1),(1399,18, 349, -1),(1399,18, 272, -1),(1399,17, 370, -1),(1399,17, 288, -1),(1399,16, 393, -1),(1399,16, 306, -1),(1399,15, 326, -1),(1399,15, 233, -1),(1399,14, 349, -1),(1399,13, 376, -1),(1399,13, 269, -1),(1399,12, 291, -1),(1399,11, 317, -1),(1399,11, 190, -1),(1399,10, 349, -1),(1399,9, 388, -1),(1399,9, 233, -1),(1399,8, 262, -1),(1399,7, 299, -1),(1399,6, 349, -1),(1399,5, 399, -1),(1398,5, 399, -1),(1397,5, 399, -1),(1396,5, 399, -1),(1395,5, 399, -1),(1394,5, 399, -1),(1393,5, 399, -1),(1392,5, 399, -1),(1391,5, 399, -1),(1390,5, 399, -1),(1389,5, 399, -1),(1388,5, 399, -1),(1387,5, 399, -1),(1386,5, 399, -1),(1385,5, 399, -1),(1384,5, 399, -1),(1383,5, 399, -1),(1382,5, 399, -1),(1381,5, 399, -1),(1380,5, 399, -1),(1379,5, 399, -1),(1378,5, 399, -1),(1377,5, 399, -1),(1376,5, 399, -1),(1375,5, 399, -1),(1374,5, 399, -1),(1373,5, 399, -1),(1372,5, 399, -1),(1371,5, 399, -1),(1370,5, 399, -1),(1369,5, 399, -1),(1368,5, 399, -1),(1367,5, 399, -1),(1366,5, 399, -1),(1365,5, 399, -1),(1364,5, 399, -1),(1363,5, 399, -1),(1362,5, 399, -1),(1361,5, 399, -1),(1360,5, 399, -1),(1359,5, 399, -1),(1358,5, 399, -1),(1357,5, 399, -1),(1356,5, 399, -1),(1355,5, 399, -1),(1354,5, 399, -1),(1353,5, 399, -1),(1352,5, 399, -1),(1351,5, 399, -1),(1350,5, 399, -1),(1349,5, 399, -1),(1348,5, 399, -1),(1347,5, 399, -1),(1346,5, 399, -1),(1345,5, 399, -1),(1344,5, 399, -1),(1343,5, 399, -1),(1342,5, 399, -1),(1341,5, 399, -1),(1340,5, 399, -1),(1339,5, 399, -1),(1338,5, 399, -1),(1337,5, 399, -1),(1336,5, 399, -1),(1335,5, 399, -1),(1334,5, 399, -1),(1333,5, 399, -1),(1332,5, 399, -1),(1331,5, 399, -1),(697,1399, -1, 280),(689,1399, -1, 200),(683,1399, -1, 212),(674,1398, -1, 28),(667,1398, -1, 284),(660,1399, -1, 124),(654,1399, -1, 123),(647,1399, -1, 40),(641,1399, -1, 287),(635,1398, -1, 142),(629,1398, -1, 10),(623,1398, -1, 46),(618,1399, -1, 103),(612,1399, -1, 8),(606,1399, -1, 202),(600,1399, -1, 232),(594,1399, -1, 305),(588,1397, -1, 177),(582,1399, -1, 256),(577,1399, -1, 217),(571,1396, -1, 11),(567,1399, -1, 359),(563,1399, -1, 41),(557,1399, -1, 162),(552,1399, -1, 313),(547,1399, -1, 211),(541,1399, -1, 128),(536,1399, -1, 338),(532,1398, -1, 293),(527,1399, -1, 73),(521,1398, -1, 377),(517,1399, -1, 115),(513,1397, -1, 241),(509,1399, -1, 224),(505,1396, -1, 217),(502,1398, -1, 110),(498,1397, -1, 108),(495,1399, -1, 366),(490,1398, -1, 398),(485,1397, -1, 301),(482,1398, -1, 364),(479,1399, -1, 92),(474,1399, -1, 152),(470,1398, -1, 58),(467,1399, -1, 349),(463,1399, -1, 210),(459,1399, -1, 32),(456,1399, -1, 158),(452,1398, -1, 283),(449,1399, -1, 148),(445,1399, -1, 11),(442,1398, -1, 68),(439,1398, -1, 164),(436,1398, -1, 101),(433,1399, -1, 63),(430,1399, -1, 353),(426,1399, -1, 133),(423,1399, -1, 339),(419,1399, -1, 202),(417,1399, -1, 52),(413,1399, -1, 188),(409,1396, -1, 285),(406,1399, -1, 143),(402,1397, -1, 384),(400,1399, -1, 348),(397,1399, -1, 111),(393,1399, -1, 283),(391,1399, -1, 195),(388,1399, -1, 384),(385,1398, -1, 187),(383,1399, -1, 305),(380,1399, -1, 289),(377,1399, -1, 154),(374,1398, -1, 271),(372,1399, -1, 220),(370,1399, -1, 259),(368,1398, -1, 340),(366,1399, -1, 86),(364,1397, -1, 71),(361,1398, -1, 91),(359,1399, -1, 189),(356,1397, -1, 155),(355,1399, -1, 333),(351,1399, -1, 277),(349,1398, -1, 6),(347,1398, -1, 280),(345,1399, -1, 221),(341,1399, -1, 240),(338,1399, -1, 387),(335,1399, -1, 332),(333,1399, -1, 355),(330,1399, -1, 248),(328,1399, -1, 386),(326,1399, -1, 324),(324,1399, -1, 326),(322,1398, -1, 89),(320,1398, -1, 391),(318,1397, -1, 380),(317,1399, -1, 267),(315,1397, -1, 51),(313,1399, -1, 257),(311,1398, -1, 227),(310,1399, -1, 264),(309,1399, -1, 206),(307,1399, -1, 180),(305,1399, -1, 383),(304,1399, -1, 237),(301,1399, -1, 244),(299,1399, -1, 386),(299,1399, -1, 255),(296,1398, -1, 196),(294,1397, -1, 354),(292,1399, -1, 103),(291,1397, -1, 12),(289,1399, -1, 380),(288,1399, -1, 17),(286,1399, -1, 203),(284,1397, -1, 273),(283,1399, -1, 393),(281,1397, -1, 261),(280,1398, -1, 347),(278,1399, -1, 390),(277,1399, -1, 250),(275,1399, -1, 379),(273,1397, -1, 284),(272,1399, -1, 54),(270,1398, -1, 277),(269,1399, -1, 65),(267,1399, -1, 317),(265,1398, -1, 182),(263,1399, -1, 125),(262,1398, -1, 8),(261,1399, -1, 201),(259,1399, -1, 397),(258,1399, -1, 122),(257,1399, -1, 313),(256,1399, -1, 194),(255,1399, -1, 299),(253,1399, -1, 235),(252,1399, -1, 297),(251,1398, -1, 220),(250,1399, -1, 277),(248,1399, -1, 330),(247,1399, -1, 337),(246,1399, -1, 327),(245,1399, -1, 197),(244,1399, -1, 43),(242,1399, -1, 211),(241,1399, -1, 357),(240,1399, -1, 341),(238,1399, -1, 385),(238,1398, -1, 326),(237,1399, -1, 304),(236,1399, -1, 329),(234,1399, -1, 278),(233,1399, -1, 9),(232,1397, -1, 280),(230,1399, -1, 295),(229,1399, -1, 168),(228,1399, -1, 316),(226,1398, -1, 300),(225,1398, -1, 146),(224,1399, -1, 153),(223,1399, -1, 367),(221,1399, -1, 345),(220,1399, -1, 124),(219,1399, -1, 214),(218,1399, -1, 369),(216,1399, -1, 204),(216,1399, -1, 68),(215,1399, -1, 244),(214,1399, -1, 219),(213,1399, -1, 266),(212,1397, -1, 313),(211,1399, -1, 242),(209,1399, -1, 251),(208,1399, -1, 380),(207,1398, -1, 260),(206,1399, -1, 309),(205,1399, -1, 58),(204,1399, -1, 120),(203,1399, -1, 286),(202,1398, -1, 218),(201,1399, -1, 261),(200,1398, -1, 346),(199,1396, -1, 235),(198,1399, -1, 378),(197,1399, -1, 316),(197,1399, -1, 245),(196,1399, -1, 389),(195,1399, -1, 391),(194,1399, -1, 256),(193,1398, -1, 134),(191,1399, -1, 260),(191,1398, -1, 172),(189,1399, -1, 396),(189,1399, -1, 359),(188,1398, -1, 145),(187,1398, -1, 385),(186,1399, -1, 267),(185,1399, -1, 397),(184,1399, -1, 19),(183,1399, -1, 172),(182,1399, -1, 319),(181,1399, -1, 228),(180,1399, -1, 307),(179,1399, -1, 254),(178,1399, -1, 279),(177,1399, -1, 328),(176,1399, -1, 155),(175,1396, -1, 347),(174,1399, -1, 205),(173,1399, -1, 376),(173,1398, -1, 101),(172,1399, -1, 305),(172,1399, -1, 61),(171,1397, -1, 241),(170,1399, -1, 144),(169,1398, -1, 335),(168,1399, -1, 229),(167,1399, -1, 289),(166,1398, -1, 240),(166,1398, -1, 80),(165,1398, -1, 72),(164,1399, -1, 354),(163,1399, -1, 339),(162,1399, -1, 367),(162,1397, -1, 332),(161,1398, -1, 178),(160,1399, -1, 153),(159,1396, -1, 259),(158,1399, -1, 394),(157,1399, -1, 147),(157,1399, -1, 49),(156,1399, -1, 139),(155,1399, -1, 176),(154,1399, -1, 377),(153,1399, -1, 224),(153,1399, -1, 96),(152,1399, -1, 69),(152,1399, -1, 23),(151,1399, -1, 88),(150,1398, -1, 219),(149,1399, -1, 399),(149,1399, -1, 61),(149,1396, -1, 89),(148,1399, -1, 345),(148,1398, -1, 392),(147,1399, -1, 157),(146,1399, -1, 321),(145,1399, -1, 82),(144,1399, -1, 306),(144,1399, -1, 170),(144,1399, -1, 34),(143,1399, -1, 44),(142,1399, -1, 399),(141,1399, -1, 253),(140,1396, -1, 344),(139,1399, -1, 317),(139,1399, -1, 156),(138,1399, -1, 370),(138,1397, -1, 329),(137,1399, -1, 291),(137,1399, -1, 97),(136,1399, -1, 324),(136,1399, -1, 180),(136,1399, -1, 36),(135,1398, -1, 88),(135,1397, -1, 119),(134,1399, -1, 214),(134,1398, -1, 193),(133,1399, -1, 142),(132,1398, -1, 323),(131,1399, -1, 315),(131,1399, -1, 283),(130,1399, -1, 382),(130,1398, -1, 371),(129,1399, -1, 244),(128,1399, -1, 388),(127,1399, -1, 369),(127,1399, -1, 358),(126,1399, -1, 272),(125,1399, -1, 263),(124,1399, -1, 220),(123,1399, -1, 381),(122,1399, -1, 258),(121,1399, -1, 237),(120,1399, -1, 204),(119,1399, -1, 335),(119,1399, -1, 288),(119,1399, -1, 241),(118,1399, -1, 326),(118,1398, -1, 77),(117,1399, -1, 269),(117,1397, -1, 197),(116,1399, -1, 211),(116,1398, -1, 235),(115,1399, -1, 371),(115,1398, -1, 79),(114,1399, -1, 362),(113,1399, -1, 229),(113,1398, -1, 167),(112,1399, -1, 331),(111,1399, -1, 397),(110,1399, -1, 337),(110,1399, -1, 248),(109,1398, -1, 109),(108,1399, -1, 136),(107,1399, -1, 268),(106,1398, -1, 389),(106,1398, -1, 178),(106,1397, -1, 112),(105,1399, -1, 393),(105,1397, -1, 153),(104,1399, -1, 343),(103,1399, -1, 292),(102,1399, -1, 336),(102,1399, -1, 144),(101,1399, -1, 367),(101,1399, -1, 90),(100,1396, -1, 342),(99,1399, -1, 332),(99,1399, -1, 219),(98,1399, -1, 364),(97,1399, -1, 310),(97,1399, -1, 137),(96,1399, -1, 357),(96,1399, -1, 255),(96,1399, -1, 153),(96,1399, -1, 51),(95,1399, -1, 346),(95,1397, -1, 272),(95,1396, -1, 360),(94,1399, -1, 305),(94,1399, -1, 186),(94,1398, -1, 290),(93,1399, -1, 203),(93,1399, -1, 188),(93,1397, -1, 353),(92,1399, -1, 190),(92,1399, -1, 114),(92,1399, -1, 38),(91,1399, -1, 392),(91,1397, -1, 284),(90,1399, -1, 272),(89,1399, -1, 275),(89,1399, -1, 165),(89,1399, -1, 55),(88,1399, -1, 310),(87,1399, -1, 217),(87,1399, -1, 201),(86,1399, -1, 366),(86,1399, -1, 122),(85,1399, -1, 288),(85,1398, -1, 74),(84,1399, -1, 358),(84,1398, -1, 208),(83,1399, -1, 396),(83,1398, -1, 261),(83,1398, -1, 160),(82,1399, -1, 162),(82,1399, -1, 145),(81,1399, -1, 354),(81,1398, -1, 302),(80,1399, -1, 341),(79,1399, -1, 363),(78,1399, -1, 278),(77,1399, -1, 227),(77,1399, -1, 118),(77,1398, -1, 354),(77,1398, -1, 118),(76,1399, -1, 230),(76,1399, -1, 138),(76,1399, -1, 46),(75,1399, -1, 345),(75,1396, -1, 214),(75,1394, -1, 381),(75,1393, -1, 65),(74,1399, -1, 293),(74,1398, -1, 85),(73,1399, -1, 297),(73,1398, -1, 67),(72,1399, -1, 340),(72,1399, -1, 204),(72,1399, -1, 68),(71,1399, -1, 325),(71,1399, -1, 266),(70,1396, -1, 329),(70,1395, -1, 269),(70,1394, -1, 229),(69,1399, -1, 375),(69,1399, -1, 152),(69,1398, -1, 314),(68,1399, -1, 360),(68,1399, -1, 216),(68,1399, -1, 72),(67,1399, -1, 261),(66,1399, -1, 392),(66,1398, -1, 180),(65,1399, -1, 398),(65,1399, -1, 355),(65,1399, -1, 312),(65,1399, -1, 269),(64,1399, -1, 295),(64,1399, -1, 142),(64,1398, -1, 273),(64,1397, -1, 251),(63,1399, -1, 344),(63,1399, -1, 233),(63,1399, -1, 122),(63,1398, -1, 122),(63,1397, -1, 255),(62,1399, -1, 327),(62,1399, -1, 282),(62,1398, -1, 124),(61,1399, -1, 172),(60,1399, -1, 338),(60,1398, -1, 198),(59,1399, -1, 391),(59,1399, -1, 320),(59,1398, -1, 154),(58,1399, -1, 229),(58,1399, -1, 205),(57,1399, -1, 233),(57,1399, -1, 184),(57,1398, -1, 282),(56,1399, -1, 387),(56,1398, -1, 337),(55,1399, -1, 267),(55,1399, -1, 89),(54,1399, -1, 272),(53,1399, -1, 277),(53,1398, -1, 356),(53,1398, -1, 145),(53,1397, -1, 224),(53,1395, -1, 329),(52,1399, -1, 390),(52,1399, -1, 121),(52,1397, -1, 94),(51,1399, -1, 288),(51,1399, -1, 96),(50,1397, -1, 377),(50,1396, -1, 321),(50,1395, -1, 265),(49,1399, -1, 328),(49,1399, -1, 271),(49,1399, -1, 214),(49,1399, -1, 157),(48,1399, -1, 335),(48,1399, -1, 306),(48,1399, -1, 102),(47,1399, -1, 372),(47,1399, -1, 253),(46,1399, -1, 380),(46,1399, -1, 228),(46,1399, -1, 76),(45,1399, -1, 295),(45,1399, -1, 264),(45,1399, -1, 233),(45,1399, -1, 202),(45,1397, -1, 357),(44,1399, -1, 302),(43,1399, -1, 374),(43,1399, -1, 309),(43,1399, -1, 244),(42,1399, -1, 383),(41,1399, -1, 392),(41,1399, -1, 358),(41,1399, -1, 324),(41,1399, -1, 290),(40,1399, -1, 367),(40,1398, -1, 332),(39,1399, -1, 269),(38,1399, -1, 276),(38,1399, -1, 92),(37,1399, -1, 397),(36,1399, -1, 369),(36,1399, -1, 136),(35,1398, -1, 379),(35,1397, -1, 379),(35,1396, -1, 339),(34,1399, -1, 308),(34,1399, -1, 267),(34,1399, -1, 226),(34,1399, -1, 185),(34,1399, -1, 144),(33,1399, -1, 360),(33,1399, -1, 233),(33,1398, -1, 360),(32,1399, -1, 371),(32,1399, -1, 284),(32,1399, -1, 153),(31,1399, -1, 383),(31,1399, -1, 338),(31,1399, -1, 293),(31,1399, -1, 248),(31,1399, -1, 203),(31,1398, -1, 248),(30,1399, -1, 396),(30,1399, -1, 303),(30,1396, -1, 349),(29,1399, -1, 361),(29,1399, -1, 313),(29,1399, -1, 265),(29,1399, -1, 217),(28,1399, -1, 374),(28,1398, -1, 374),(28,1397, -1, 374),(28,1396, -1, 324),(28,1395, -1, 274),(27,1399, -1, 388),(27,1399, -1, 233),(27,1397, -1, 388),(26,1399, -1, 349),(26,1399, -1, 242),(26,1397, -1, 188),(25,1399, -1, 363),(25,1398, -1, 363),(25,1397, -1, 363),(25,1396, -1, 307),(25,1393, -1, 195),(24,1399, -1, 378),(24,1399, -1, 320),(24,1399, -1, 262),(24,1399, -1, 204),(23,1399, -1, 395),(23,1399, -1, 152),(22,1399, -1, 349),(22,1399, -1, 286),(21,1399, -1, 366),(21,1399, -1, 233),(20,1399, -1, 384),(20,1398, -1, 384),(20,1397, -1, 384),(20,1396, -1, 314),(19,1399, -1, 331),(19,1399, -1, 184),(18,1399, -1, 349),(18,1399, -1, 272),(17,1399, -1, 370),(17,1399, -1, 288),(16,1399, -1, 393),(16,1399, -1, 306),(15,1399, -1, 326),(15,1399, -1, 233),(14,1399, -1, 349),(14,1398, -1, 349),(14,1397, -1, 349),(14,1395, -1, 249),(13,1399, -1, 376),(13,1399, -1, 269),(12,1399, -1, 291),(12,1398, -1, 291),(12,1397, -1, 291),(11,1399, -1, 317),(11,1399, -1, 190),(11,1398, -1, 190),(11,1397, -1, 317),(11,1396, -1, 317),(11,1395, -1, 317),(10,1399, -1, 349),(10,1398, -1, 349),(10,1397, -1, 349),(9,1399, -1, 388),(9,1399, -1, 233),(8,1399, -1, 262),(8,1398, -1, 262),(7,1399, -1, 299),(7,1398, -1, 299),(7,1397, -1, 299),(7,1396, -1, 299),(6,1399, -1, 349),(6,1398, -1, 349),(6,1397, -1, 349),(5,1399, -1, 399),(5,1398, -1, 399),(5,1397, -1, 399),(5,1396, -1, 399),(5,1395, -1, 399),(5,1394, -1, 399),(5,1393, -1, 399),(5,1392, -1, 399),(5,1391, -1, 399),(5,1390, -1, 399),(5,1389, -1, 399),(5,1388, -1, 399),(5,1387, -1, 399),(5,1386, -1, 399),(5,1385, -1, 399),(5,1384, -1, 399),(5,1383, -1, 399),(5,1382, -1, 399),(5,1381, -1, 399),(5,1380, -1, 399),(5,1379, -1, 399),(5,1378, -1, 399),(5,1377, -1, 399),(5,1376, -1, 399),(5,1375, -1, 399),(5,1374, -1, 399),(5,1373, -1, 399),(5,1372, -1, 399),(5,1371, -1, 399),(5,1370, -1, 399),(5,1369, -1, 399),(5,1368, -1, 399),(5,1367, -1, 399),(5,1366, -1, 399),(5,1365, -1, 399),(5,1364, -1, 399),(5,1363, -1, 399),(5,1362, -1, 399),(5,1361, -1, 399),(5,1360, -1, 399),(5,1359, -1, 399),(5,1358, -1, 399),(5,1357, -1, 399),(5,1356, -1, 399),(5,1355, -1, 399),(5,1354, -1, 399),(5,1353, -1, 399),(5,1352, -1, 399),(5,1351, -1, 399),(5,1350, -1, 399),(5,1349, -1, 399),(5,1348, -1, 399),(5,1347, -1, 399),(5,1346, -1, 399),(5,1345, -1, 399),(5,1344, -1, 399),(5,1343, -1, 399),(5,1342, -1, 399),(5,1341, -1, 399),(5,1340, -1, 399),(5,1339, -1, 399),(5,1338, -1, 399),(5,1337, -1, 399),(5,1336, -1, 399),(5,1335, -1, 399),(5,1334, -1, 399),(5,1333, -1, 399),(5,1332, -1, 399),(5,1331, -1, 399)];
1523
1524    #[test]
1525    fn rounding_regression_shrink_within() {
1526        let mut failures = Vec::new();
1527        for (i, &(ow, oh, tw, th)) in SHRINK_WITHIN_TESTS.iter().enumerate() {
1528            let ow = ow as u32;
1529            let oh = oh as u32;
1530            if tw > 0 {
1531                let tw = tw as u32;
1532                let layout = Constraint::width_only(ConstraintMode::Fit, tw)
1533                    .compute(ow, oh)
1534                    .unwrap();
1535                if layout.resize_to.width != tw {
1536                    failures.push(format!(
1537                        "case {i}: ({ow}x{oh}, w={tw}) -> resize_to.0={}, expected {tw}",
1538                        layout.resize_to.width
1539                    ));
1540                }
1541                if layout.source_crop.is_some() {
1542                    failures.push(format!(
1543                        "case {i}: ({ow}x{oh}, w={tw}) -> unexpected source_crop"
1544                    ));
1545                }
1546            } else if th > 0 {
1547                let th = th as u32;
1548                let layout = Constraint::height_only(ConstraintMode::Fit, th)
1549                    .compute(ow, oh)
1550                    .unwrap();
1551                if layout.resize_to.height != th {
1552                    failures.push(format!(
1553                        "case {i}: ({ow}x{oh}, h={th}) -> resize_to.1={}, expected {th}",
1554                        layout.resize_to.height
1555                    ));
1556                }
1557                if layout.source_crop.is_some() {
1558                    failures.push(format!(
1559                        "case {i}: ({ow}x{oh}, h={th}) -> unexpected source_crop"
1560                    ));
1561                }
1562            }
1563        }
1564        assert!(
1565            failures.is_empty(),
1566            "Rounding regression failures ({} of {}):\n{}",
1567            failures.len(),
1568            SHRINK_WITHIN_TESTS.len(),
1569            failures.join("\n")
1570        );
1571    }
1572
1573    // ════════════════════════════════════════════════════════════════════
1574    // Step 2: Parametric invariant tests
1575    // ════════════════════════════════════════════════════════════════════
1576
1577    const TARGETS: [(u32, u32); 11] = [
1578        (1, 1),
1579        (1, 3),
1580        (3, 1),
1581        (7, 3),
1582        (90, 45),
1583        (10, 10),
1584        (100, 33),
1585        (1621, 883),
1586        (971, 967),
1587        (17, 1871),
1588        (512, 512),
1589    ];
1590
1591    fn gen_source_sizes(tw: u32, th: u32) -> Vec<(u32, u32)> {
1592        fn vary(v: u32) -> Vec<u32> {
1593            let mut vals = vec![v, v.saturating_add(1), v.saturating_sub(1).max(1)];
1594            vals.extend([v * 2, v * 3, v * 10]);
1595            vals.extend([(v / 2).max(1), (v / 3).max(1), (v / 10).max(1)]);
1596            vals.push(v.next_power_of_two());
1597            vals.extend([1, 2, 3, 5, 7, 16, 100, 1000]);
1598            vals.sort_unstable();
1599            vals.dedup();
1600            vals.retain(|&x| x > 0);
1601            vals
1602        }
1603
1604        let w_vals = vary(tw);
1605        let h_vals = vary(th);
1606        let mut sizes: Vec<(u32, u32)> = Vec::new();
1607        for &w in &w_vals {
1608            for &h in &h_vals {
1609                sizes.push((w, h));
1610            }
1611        }
1612
1613        // Aspect ratio inner/outer boxes
1614        let ratios: [(u64, u64); 6] = [(1, 1), (1, 3), (3, 1), (4, 3), (16, 9), (1200, 400)];
1615        for (rw, rh) in ratios {
1616            let inner_w = (tw as u64).min(th as u64 * rw / rh.max(1)).max(1) as u32;
1617            let inner_h = (th as u64).min(tw as u64 * rh / rw.max(1)).max(1) as u32;
1618            sizes.push((inner_w, inner_h));
1619            let outer_w = (tw as u64).max(th as u64 * rw / rh.max(1)).max(1) as u32;
1620            let outer_h = (th as u64).max(tw as u64 * rh / rw.max(1)).max(1) as u32;
1621            sizes.push((outer_w, outer_h));
1622        }
1623
1624        sizes.sort_unstable();
1625        sizes.dedup();
1626        sizes
1627    }
1628
1629    #[test]
1630    fn parametric_invariants() {
1631        let mut failures = Vec::new();
1632        let mut checked = 0u64;
1633
1634        for &(tw, th) in &TARGETS {
1635            let sources = gen_source_sizes(tw, th);
1636            for &(sw, sh) in &sources {
1637                use ConstraintMode::*;
1638                let modes = [
1639                    Distort, Fit, Within, FitCrop, WithinCrop, FitPad, WithinPad, AspectCrop,
1640                ];
1641                for mode in modes {
1642                    let c = Constraint::new(mode, tw, th);
1643                    let layout = match c.compute(sw, sh) {
1644                        Ok(l) => l,
1645                        Err(e) => {
1646                            failures
1647                                .push(format!("{mode:?} ({sw}x{sh} -> {tw}x{th}): error {e:?}"));
1648                            continue;
1649                        }
1650                    };
1651                    let Size {
1652                        width: rw,
1653                        height: rh,
1654                    } = layout.resize_to;
1655                    let Size {
1656                        width: cw,
1657                        height: ch,
1658                    } = layout.canvas;
1659                    let (px, py) = layout.placement;
1660                    let tag = format!("{mode:?} ({sw}x{sh} -> {tw}x{th})");
1661
1662                    match mode {
1663                        Distort => {
1664                            if (cw, ch) != (tw, th) {
1665                                failures.push(format!(
1666                                    "{tag}: canvas ({cw},{ch}) != target ({tw},{th})"
1667                                ));
1668                            }
1669                            if (rw, rh) != (tw, th) {
1670                                failures.push(format!(
1671                                    "{tag}: resize_to ({rw},{rh}) != target ({tw},{th})"
1672                                ));
1673                            }
1674                            if layout.source_crop.is_some() {
1675                                failures.push(format!("{tag}: unexpected source_crop"));
1676                            }
1677                        }
1678                        Fit => {
1679                            if rw > tw || rh > th {
1680                                failures.push(format!(
1681                                    "{tag}: resize_to ({rw},{rh}) exceeds target ({tw},{th})"
1682                                ));
1683                            }
1684                            if rw != tw && rh != th {
1685                                failures.push(format!(
1686                                    "{tag}: doesn't touch either edge: ({rw},{rh}) vs ({tw},{th})"
1687                                ));
1688                            }
1689                            if layout.source_crop.is_some() {
1690                                failures.push(format!("{tag}: unexpected source_crop"));
1691                            }
1692                            if (cw, ch) != (rw, rh) {
1693                                failures.push(format!(
1694                                    "{tag}: canvas ({cw},{ch}) != resize_to ({rw},{rh})"
1695                                ));
1696                            }
1697                        }
1698                        Within => {
1699                            if rw > tw || rh > th {
1700                                failures.push(format!(
1701                                    "{tag}: resize_to ({rw},{rh}) exceeds target ({tw},{th})"
1702                                ));
1703                            }
1704                            if sw <= tw && sh <= th {
1705                                if (rw, rh) != (sw, sh) {
1706                                    failures.push(format!(
1707                                        "{tag}: no-upscale: ({rw},{rh}) != source ({sw},{sh})"
1708                                    ));
1709                                }
1710                            } else if rw != tw && rh != th {
1711                                failures.push(format!(
1712                                    "{tag}: doesn't touch either edge: ({rw},{rh}) vs ({tw},{th})"
1713                                ));
1714                            }
1715                            if layout.source_crop.is_some() {
1716                                failures.push(format!("{tag}: unexpected source_crop"));
1717                            }
1718                            if (cw, ch) != (rw, rh) {
1719                                failures.push(format!(
1720                                    "{tag}: canvas ({cw},{ch}) != resize_to ({rw},{rh})"
1721                                ));
1722                            }
1723                        }
1724                        FitCrop => {
1725                            if (cw, ch) != (tw, th) {
1726                                failures.push(format!(
1727                                    "{tag}: canvas ({cw},{ch}) != target ({tw},{th})"
1728                                ));
1729                            }
1730                            if (rw, rh) != (tw, th) {
1731                                failures.push(format!(
1732                                    "{tag}: resize_to ({rw},{rh}) != target ({tw},{th})"
1733                                ));
1734                            }
1735                            if let Some(crop) = &layout.source_crop
1736                                && crop.x > 0
1737                                && crop.y > 0
1738                                && crop.x + crop.width < sw
1739                                && crop.y + crop.height < sh
1740                            {
1741                                failures.push(format!("{tag}: crop on all 4 sides: {crop:?}"));
1742                            }
1743                        }
1744                        WithinCrop => {
1745                            if rw > tw || rh > th {
1746                                failures.push(format!(
1747                                    "{tag}: resize_to ({rw},{rh}) exceeds target ({tw},{th})"
1748                                ));
1749                            }
1750                            if let Some(crop) = &layout.source_crop {
1751                                if rw > crop.width || rh > crop.height {
1752                                    failures.push(format!(
1753                                        "{tag}: upscale: resize ({rw},{rh}) > crop ({},{})",
1754                                        crop.width, crop.height
1755                                    ));
1756                                }
1757                                if crop.x > 0
1758                                    && crop.y > 0
1759                                    && crop.x + crop.width < sw
1760                                    && crop.y + crop.height < sh
1761                                {
1762                                    failures.push(format!("{tag}: crop on all 4 sides: {crop:?}"));
1763                                }
1764                            }
1765                        }
1766                        FitPad => {
1767                            if (cw, ch) != (tw, th) {
1768                                failures.push(format!(
1769                                    "{tag}: canvas ({cw},{ch}) != target ({tw},{th})"
1770                                ));
1771                            }
1772                            if rw > tw || rh > th {
1773                                failures.push(format!(
1774                                    "{tag}: resize_to ({rw},{rh}) exceeds target ({tw},{th})"
1775                                ));
1776                            }
1777                            if layout.source_crop.is_some() {
1778                                failures.push(format!("{tag}: unexpected source_crop"));
1779                            }
1780                            if px > 0
1781                                && py > 0
1782                                && px + rw as i32 > 0
1783                                && (px as u32) + rw < tw
1784                                && (py as u32) + rh < th
1785                            {
1786                                failures.push(format!("{tag}: padding on all 4 sides"));
1787                            }
1788                            if px as u32 + rw > tw || py as u32 + rh > th {
1789                                failures.push(format!(
1790                                    "{tag}: placement overflow: ({px},{py})+({rw},{rh})>({tw},{th})"
1791                                ));
1792                            }
1793                        }
1794                        WithinPad => {
1795                            if sw <= tw && sh <= th {
1796                                // Source fits within target → identity (imageflow behavior)
1797                                if (rw, rh) != (sw, sh) {
1798                                    failures.push(format!(
1799                                        "{tag}: no-upscale: ({rw},{rh}) != source ({sw},{sh})"
1800                                    ));
1801                                }
1802                                if (cw, ch) != (sw, sh) {
1803                                    failures.push(format!(
1804                                        "{tag}: identity canvas ({cw},{ch}) != source ({sw},{sh})"
1805                                    ));
1806                                }
1807                            } else {
1808                                // Source exceeds target on at least one axis → downscale + pad
1809                                if (cw, ch) != (tw, th) {
1810                                    failures.push(format!(
1811                                        "{tag}: canvas ({cw},{ch}) != target ({tw},{th})"
1812                                    ));
1813                                }
1814                                if rw > tw || rh > th {
1815                                    failures.push(format!(
1816                                        "{tag}: resize_to ({rw},{rh}) exceeds target ({tw},{th})"
1817                                    ));
1818                                }
1819                            }
1820                            if layout.source_crop.is_some() {
1821                                failures.push(format!("{tag}: unexpected source_crop"));
1822                            }
1823                            if px as u32 + rw > cw || py as u32 + rh > ch {
1824                                failures.push(format!(
1825                                    "{tag}: placement overflow: ({px},{py})+({rw},{rh})>({cw},{ch})"
1826                                ));
1827                            }
1828                        }
1829                        PadWithin => {
1830                            // Canvas is always target dimensions
1831                            if (cw, ch) != (tw, th) {
1832                                failures.push(format!(
1833                                    "{tag}: canvas ({cw},{ch}) != target ({tw},{th})"
1834                                ));
1835                            }
1836                            // Never upscales
1837                            if rw > sw || rh > sh {
1838                                failures.push(format!(
1839                                    "{tag}: upscaled: resize ({rw},{rh}) > source ({sw},{sh})"
1840                                ));
1841                            }
1842                            // resize_to fits within target
1843                            if rw > tw || rh > th {
1844                                failures.push(format!(
1845                                    "{tag}: resize_to ({rw},{rh}) exceeds target ({tw},{th})"
1846                                ));
1847                            }
1848                            if layout.source_crop.is_some() {
1849                                failures.push(format!("{tag}: unexpected source_crop"));
1850                            }
1851                            if px as u32 + rw > cw || py as u32 + rh > ch {
1852                                failures.push(format!(
1853                                    "{tag}: placement overflow: ({px},{py})+({rw},{rh})>({cw},{ch})"
1854                                ));
1855                            }
1856                        }
1857                        AspectCrop => {
1858                            if let Some(crop) = &layout.source_crop {
1859                                if (rw, rh) != (crop.width, crop.height) {
1860                                    failures.push(format!(
1861                                        "{tag}: resize_to ({rw},{rh}) != crop ({},{})",
1862                                        crop.width, crop.height
1863                                    ));
1864                                }
1865                            } else if (rw, rh) != (sw, sh) {
1866                                failures.push(format!(
1867                                    "{tag}: no crop but resize ({rw},{rh}) != source ({sw},{sh})"
1868                                ));
1869                            }
1870                            if (cw, ch) != (rw, rh) {
1871                                failures.push(format!(
1872                                    "{tag}: canvas ({cw},{ch}) != resize_to ({rw},{rh})"
1873                                ));
1874                            }
1875                        }
1876                    }
1877                    checked += 1;
1878                }
1879            }
1880        }
1881
1882        assert!(
1883            failures.is_empty(),
1884            "Parametric invariant failures ({} of {checked} checked):\n{}",
1885            failures.len(),
1886            failures.join("\n")
1887        );
1888        // Ensure we actually checked a reasonable number of combinations
1889        assert!(
1890            checked > 15_000,
1891            "Only checked {checked} combinations, expected >15,000"
1892        );
1893    }
1894
1895    // ════════════════════════════════════════════════════════════════════
1896    // Step 3: Specific rounding edge cases
1897    // ════════════════════════════════════════════════════════════════════
1898
1899    #[test]
1900    fn rounding_1200x400_to_100x33_fit() {
1901        let l = Constraint::new(ConstraintMode::Fit, 100, 33)
1902            .compute(1200, 400)
1903            .unwrap();
1904        assert_eq!(l.resize_to, Size::new(100, 33));
1905        assert!(l.source_crop.is_none());
1906    }
1907
1908    #[test]
1909    fn rounding_1200x400_to_100x33_fit_crop() {
1910        let l = Constraint::new(ConstraintMode::FitCrop, 100, 33)
1911            .compute(1200, 400)
1912            .unwrap();
1913        assert_eq!(l.resize_to, Size::new(100, 33));
1914        assert_eq!(l.canvas, Size::new(100, 33));
1915    }
1916
1917    #[test]
1918    fn crop_aspect_638x423_to_200x133() {
1919        let l = Constraint::new(ConstraintMode::FitCrop, 200, 133)
1920            .compute(638, 423)
1921            .unwrap();
1922        assert_eq!(l.resize_to, Size::new(200, 133));
1923        assert_eq!(l.canvas, Size::new(200, 133));
1924    }
1925
1926    #[test]
1927    fn fit_2x4_to_1x3() {
1928        let l = Constraint::new(ConstraintMode::Fit, 1, 3)
1929            .compute(2, 4)
1930            .unwrap();
1931        assert!(l.resize_to.width <= 1);
1932        assert!(l.resize_to.height <= 3);
1933        assert!(l.resize_to.width == 1 || l.resize_to.height == 3);
1934    }
1935
1936    #[test]
1937    fn fit_crop_2x4_to_1x3() {
1938        let l = Constraint::new(ConstraintMode::FitCrop, 1, 3)
1939            .compute(2, 4)
1940            .unwrap();
1941        assert_eq!(l.resize_to, Size::new(1, 3));
1942        assert_eq!(l.canvas, Size::new(1, 3));
1943    }
1944
1945    #[test]
1946    fn fit_1399x5_to_width_399() {
1947        let l = Constraint::width_only(ConstraintMode::Fit, 399)
1948            .compute(1399, 5)
1949            .unwrap();
1950        assert_eq!(l.resize_to.width, 399);
1951        assert!(l.source_crop.is_none());
1952    }
1953
1954    #[test]
1955    fn fit_5x1399_to_height_399() {
1956        let l = Constraint::height_only(ConstraintMode::Fit, 399)
1957            .compute(5, 1399)
1958            .unwrap();
1959        assert_eq!(l.resize_to.height, 399);
1960        assert!(l.source_crop.is_none());
1961    }
1962
1963    #[test]
1964    fn fit_1621x883_to_100x33() {
1965        let l = Constraint::new(ConstraintMode::Fit, 100, 33)
1966            .compute(1621, 883)
1967            .unwrap();
1968        assert!(l.resize_to.width <= 100 && l.resize_to.height <= 33);
1969        assert!(l.resize_to.width == 100 || l.resize_to.height == 33);
1970    }
1971
1972    #[test]
1973    fn fit_971x967_to_512x512() {
1974        let l = Constraint::new(ConstraintMode::Fit, 512, 512)
1975            .compute(971, 967)
1976            .unwrap();
1977        assert!(l.resize_to.width <= 512 && l.resize_to.height <= 512);
1978        assert!(l.resize_to.width == 512 || l.resize_to.height == 512);
1979    }
1980
1981    #[test]
1982    fn fit_1000x500_to_1x1() {
1983        let l = Constraint::new(ConstraintMode::Fit, 1, 1)
1984            .compute(1000, 500)
1985            .unwrap();
1986        assert_eq!(l.resize_to, Size::new(1, 1));
1987    }
1988
1989    #[test]
1990    fn fit_crop_1000x500_to_1x1() {
1991        let l = Constraint::new(ConstraintMode::FitCrop, 1, 1)
1992            .compute(1000, 500)
1993            .unwrap();
1994        assert_eq!(l.resize_to, Size::new(1, 1));
1995        assert_eq!(l.canvas, Size::new(1, 1));
1996    }
1997
1998    #[test]
1999    fn fit_pad_1000x500_to_1x1() {
2000        let l = Constraint::new(ConstraintMode::FitPad, 1, 1)
2001            .compute(1000, 500)
2002            .unwrap();
2003        assert_eq!(l.canvas, Size::new(1, 1));
2004        assert!(l.resize_to.width <= 1 && l.resize_to.height <= 1);
2005    }
2006
2007    #[test]
2008    fn fit_100x100_to_100x100() {
2009        let l = Constraint::new(ConstraintMode::Fit, 100, 100)
2010            .compute(100, 100)
2011            .unwrap();
2012        assert_eq!(l.resize_to, Size::new(100, 100));
2013        assert!(!l.needs_resize());
2014    }
2015
2016    #[test]
2017    fn fit_crop_100x100_to_100x100() {
2018        let l = Constraint::new(ConstraintMode::FitCrop, 100, 100)
2019            .compute(100, 100)
2020            .unwrap();
2021        assert_eq!(l.resize_to, Size::new(100, 100));
2022        assert!(l.source_crop.is_none());
2023    }
2024
2025    #[test]
2026    fn within_modes_no_upscale_small_source() {
2027        let modes = [
2028            ConstraintMode::Within,
2029            ConstraintMode::WithinCrop,
2030            ConstraintMode::WithinPad,
2031        ];
2032        for mode in modes {
2033            let l = Constraint::new(mode, 400, 300).compute(50, 30).unwrap();
2034            let tag = format!("{mode:?}");
2035            assert!(
2036                l.resize_to.width <= 50 && l.resize_to.height <= 30,
2037                "{tag}: upscaled to {:?}",
2038                l.resize_to
2039            );
2040        }
2041    }
2042
2043    #[test]
2044    fn width_only_1399x697_to_280() {
2045        let l = Constraint::width_only(ConstraintMode::Fit, 280)
2046            .compute(1399, 697)
2047            .unwrap();
2048        assert_eq!(l.resize_to.width, 280);
2049        assert!(l.source_crop.is_none());
2050    }
2051
2052    #[test]
2053    fn height_only_697x1399_to_280() {
2054        let l = Constraint::height_only(ConstraintMode::Fit, 280)
2055            .compute(697, 1399)
2056            .unwrap();
2057        assert_eq!(l.resize_to.height, 280);
2058        assert!(l.source_crop.is_none());
2059    }
2060
2061    // ════════════════════════════════════════════════════════════════════
2062    // Step 4: Source crop + mode composition edge cases
2063    // ════════════════════════════════════════════════════════════════════
2064
2065    #[test]
2066    fn percent_crop_99_percent_rounds_to_full() {
2067        let l = Constraint::new(ConstraintMode::Fit, 50, 50)
2068            .source_crop(SourceCrop::percent(0.0, 0.0, 0.99, 0.99))
2069            .compute(100, 100)
2070            .unwrap();
2071        // 0.99 * 100 = 99, not full source
2072        let crop = l.source_crop.unwrap();
2073        assert_eq!(crop.width, 99);
2074        assert_eq!(crop.height, 99);
2075    }
2076
2077    #[test]
2078    fn percent_crop_plus_fit_crop_extreme_aspect() {
2079        let l = Constraint::new(ConstraintMode::FitCrop, 100, 10)
2080            .source_crop(SourceCrop::percent(0.0, 0.0, 0.5, 0.5))
2081            .compute(1000, 1000)
2082            .unwrap();
2083        assert_eq!(l.resize_to, Size::new(100, 10));
2084        assert_eq!(l.canvas, Size::new(100, 10));
2085        let crop = l.source_crop.unwrap();
2086        assert!(crop.width <= 500);
2087        assert!(crop.height <= 500);
2088    }
2089
2090    #[test]
2091    fn pixel_crop_to_1x1_with_fit() {
2092        let l = Constraint::new(ConstraintMode::Fit, 200, 200)
2093            .source_crop(SourceCrop::pixels(500, 500, 1, 1))
2094            .compute(1000, 1000)
2095            .unwrap();
2096        assert_eq!(l.resize_to, Size::new(200, 200));
2097    }
2098
2099    #[test]
2100    fn pixel_crop_exceeds_source_clamped() {
2101        let l = Constraint::new(ConstraintMode::Fit, 50, 50)
2102            .source_crop(SourceCrop::pixels(900, 900, 500, 500))
2103            .compute(1000, 1000)
2104            .unwrap();
2105        let crop = l.source_crop.unwrap();
2106        assert!(crop.x + crop.width <= 1000);
2107        assert!(crop.y + crop.height <= 1000);
2108        assert!(crop.width >= 1 && crop.height >= 1);
2109    }
2110
2111    #[test]
2112    fn percent_crop_exceeds_100_clamped() {
2113        let l = Constraint::new(ConstraintMode::Fit, 50, 50)
2114            .source_crop(SourceCrop::percent(0.0, 0.0, 1.5, 1.5))
2115            .compute(100, 100)
2116            .unwrap();
2117        // Percent clamped to 1.0 -> full source -> normalized to None
2118        assert!(l.source_crop.is_none());
2119    }
2120
2121    #[test]
2122    fn percent_crop_zero_area() {
2123        let l = Constraint::new(ConstraintMode::Fit, 50, 50)
2124            .source_crop(SourceCrop::percent(0.5, 0.5, 0.0, 0.0))
2125            .compute(100, 100)
2126            .unwrap();
2127        // Zero-area clamps to min 1x1 via Rect::clamp_to
2128        let crop = l.source_crop.unwrap();
2129        assert!(crop.width >= 1 && crop.height >= 1);
2130    }
2131
2132    // ════════════════════════════════════════════════════════════════════
2133    // Step 5: Gravity edge cases
2134    // ════════════════════════════════════════════════════════════════════
2135
2136    #[test]
2137    fn gravity_center_odd_padding() {
2138        // WithinPad: source fits within target → identity (imageflow behavior).
2139        let l = Constraint::new(ConstraintMode::WithinPad, 103, 103)
2140            .compute(100, 100)
2141            .unwrap();
2142        assert_eq!(l.canvas, Size::new(100, 100));
2143        assert_eq!(l.resize_to, Size::new(100, 100));
2144        assert_eq!(l.placement, (0, 0));
2145
2146        // FitPad DOES pad when aspect differs and source < target:
2147        let l = Constraint::new(ConstraintMode::FitPad, 103, 200)
2148            .compute(100, 100)
2149            .unwrap();
2150        assert_eq!(l.canvas, Size::new(103, 200));
2151        assert_eq!(l.resize_to, Size::new(103, 103));
2152        // Center vertically: (200-103)/2 = 48
2153        assert_eq!(l.placement, (0, 48));
2154    }
2155
2156    #[test]
2157    fn gravity_percentage_clamp_negative() {
2158        let l = Constraint::new(ConstraintMode::FitPad, 400, 400)
2159            .gravity(Gravity::Percentage(-1.0, -1.0))
2160            .compute(1000, 500)
2161            .unwrap();
2162        assert_eq!(l.placement, (0, 0));
2163    }
2164
2165    #[test]
2166    fn gravity_percentage_clamp_over_1() {
2167        let l = Constraint::new(ConstraintMode::FitPad, 400, 400)
2168            .gravity(Gravity::Percentage(2.0, 2.0))
2169            .compute(1000, 500)
2170            .unwrap();
2171        let max_x = (400 - l.resize_to.width) as i32;
2172        let max_y = (400 - l.resize_to.height) as i32;
2173        assert_eq!(l.placement, (max_x, max_y));
2174    }
2175
2176    #[test]
2177    fn gravity_50_50_equals_center() {
2178        let l_pct = Constraint::new(ConstraintMode::FitPad, 400, 400)
2179            .gravity(Gravity::Percentage(0.5, 0.5))
2180            .compute(1000, 500)
2181            .unwrap();
2182        let l_center = Constraint::new(ConstraintMode::FitPad, 400, 400)
2183            .gravity(Gravity::Center)
2184            .compute(1000, 500)
2185            .unwrap();
2186        assert_eq!(l_pct.placement, l_center.placement);
2187    }
2188
2189    // ── NaN/Inf rejection ──────────────────────────────────────────────
2190
2191    #[test]
2192    fn nan_gravity_rejected() {
2193        let r = Constraint::new(ConstraintMode::FitPad, 400, 300)
2194            .gravity(Gravity::Percentage(f32::NAN, 0.5))
2195            .compute(1000, 500);
2196        assert_eq!(r, Err(LayoutError::NonFiniteFloat));
2197    }
2198
2199    #[test]
2200    fn inf_gravity_rejected() {
2201        let r = Constraint::new(ConstraintMode::FitPad, 400, 300)
2202            .gravity(Gravity::Percentage(f32::INFINITY, 0.5))
2203            .compute(1000, 500);
2204        assert_eq!(r, Err(LayoutError::NonFiniteFloat));
2205    }
2206
2207    #[test]
2208    fn nan_source_crop_rejected() {
2209        let r = Constraint::new(ConstraintMode::Fit, 400, 300)
2210            .source_crop(SourceCrop::percent(f32::NAN, 0.0, 0.5, 0.5))
2211            .compute(1000, 500);
2212        assert_eq!(r, Err(LayoutError::NonFiniteFloat));
2213    }
2214
2215    #[test]
2216    fn nan_canvas_color_rejected() {
2217        let r = Constraint::new(ConstraintMode::FitPad, 400, 300)
2218            .canvas_color(CanvasColor::Linear {
2219                r: f32::NAN,
2220                g: 0.0,
2221                b: 0.0,
2222                a: 1.0,
2223            })
2224            .compute(1000, 500);
2225        assert_eq!(r, Err(LayoutError::NonFiniteFloat));
2226    }
2227
2228    // ========================================================================
2229    // imageflow_riapi parity oracle
2230    //
2231    // Ports the core of imageflow_riapi/src/sizing.rs as a test-only oracle.
2232    // Verifies that zenlayout's constraint module produces identical results
2233    // for all overlapping modes across thousands of source×target combinations.
2234    // ========================================================================
2235
2236    #[allow(dead_code)]
2237    mod oracle {
2238        use alloc::vec;
2239        use alloc::vec::Vec;
2240        use crate::float_math::F64Ext;
2241        use core::cmp::Ordering;
2242
2243        /// Aspect ratio / size pair (mirrors imageflow's AspectRatio).
2244        #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
2245        pub struct AR {
2246            pub w: i32,
2247            pub h: i32,
2248        }
2249
2250        impl core::fmt::Debug for AR {
2251            fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2252                write!(f, "{}x{}", self.w, self.h)
2253            }
2254        }
2255
2256        impl AR {
2257            pub fn new(w: i32, h: i32) -> Self {
2258                assert!(w >= 1 && h >= 1, "AR dimensions must be >= 1, got {w}x{h}");
2259                AR { w, h }
2260            }
2261
2262            pub fn ratio_f64(&self) -> f64 {
2263                f64::from(self.w) / f64::from(self.h)
2264            }
2265
2266            pub fn aspect_wider_than(&self, other: &AR) -> bool {
2267                other.ratio_f64() > self.ratio_f64()
2268            }
2269
2270            /// Rounding-snap proportional calculation (imageflow's core rounding logic).
2271            pub fn proportional(
2272                &self,
2273                basis: i32,
2274                basis_is_width: bool,
2275                snap_target: Option<&AR>,
2276            ) -> i32 {
2277                let mut snap_amount = 1f64 - f64::EPSILON;
2278                if let Some(target) = snap_target {
2279                    if !basis_is_width {
2280                        snap_amount = self.rounding_loss_based_on_target_width(target.w);
2281                    } else {
2282                        snap_amount = self.rounding_loss_based_on_target_height(target.h);
2283                    }
2284                }
2285
2286                let ratio = self.ratio_f64();
2287                let snap_a = if basis_is_width { self.h } else { self.w };
2288                let snap_b = if let Some(target) = snap_target {
2289                    if basis_is_width { target.h } else { target.w }
2290                } else {
2291                    snap_a
2292                };
2293
2294                let float = if basis_is_width {
2295                    f64::from(basis) / ratio
2296                } else {
2297                    ratio * f64::from(basis)
2298                };
2299
2300                let delta_a = float - f64::from(snap_a);
2301                let delta_b = float - f64::from(snap_b);
2302
2303                let v = if delta_a.abs() <= snap_amount && delta_a.abs() <= delta_b.abs() {
2304                    snap_a
2305                } else if delta_b.abs() <= snap_amount {
2306                    snap_b
2307                } else {
2308                    float.round_() as i32
2309                };
2310
2311                if v <= 0 { 1 } else { v }
2312            }
2313
2314            fn rounding_loss_based_on_target_width(&self, target_width: i32) -> f64 {
2315                let target_x_to_self_x = target_width as f64 / self.w as f64;
2316                let recreate_y = self.h as f64 * target_x_to_self_x;
2317                let rounded_y = recreate_y.round_();
2318                let recreate_x_from_rounded_y = rounded_y * self.ratio_f64();
2319                (target_width as f64 - recreate_x_from_rounded_y).abs()
2320            }
2321
2322            fn rounding_loss_based_on_target_height(&self, target_height: i32) -> f64 {
2323                let target_y_to_self_y = target_height as f64 / self.h as f64;
2324                let recreate_x = self.w as f64 * target_y_to_self_y;
2325                let rounded_x = recreate_x.round_();
2326                let recreate_y_from_rounded_x = rounded_x / self.ratio_f64();
2327                (target_height as f64 - recreate_y_from_rounded_x).abs()
2328            }
2329
2330            pub fn height_for(&self, w: i32, snap: Option<&AR>) -> i32 {
2331                self.proportional(w, true, snap)
2332            }
2333
2334            pub fn width_for(&self, h: i32, snap: Option<&AR>) -> i32 {
2335                self.proportional(h, false, snap)
2336            }
2337
2338            /// Inner or outer box using own ratio.
2339            pub fn box_of(&self, target: &AR, kind: BoxKind) -> AR {
2340                if target.aspect_wider_than(self) == (kind == BoxKind::Inner) {
2341                    AR::new(target.w, self.height_for(target.w, Some(target)))
2342                } else {
2343                    AR::new(self.width_for(target.h, Some(target)), target.h)
2344                }
2345            }
2346
2347            pub fn exceeds_any(&self, other: &AR) -> bool {
2348                self.w > other.w || self.h > other.h
2349            }
2350
2351            pub fn intersection(&self, other: &AR) -> AR {
2352                AR::new(self.w.min(other.w), self.h.min(other.h))
2353            }
2354
2355            pub fn distort_with(&self, other_old: &AR, other_new: &AR) -> AR {
2356                let new_w =
2357                    (i64::from(self.w) * i64::from(other_new.w) / i64::from(other_old.w)) as i32;
2358                let new_h =
2359                    (i64::from(self.h) * i64::from(other_new.h) / i64::from(other_old.h)) as i32;
2360                AR::new(new_w.max(1), new_h.max(1))
2361            }
2362
2363            pub fn cmp_size(&self, other: &AR) -> (Ordering, Ordering) {
2364                (self.w.cmp(&other.w), self.h.cmp(&other.h))
2365            }
2366        }
2367
2368        #[derive(Copy, Clone, PartialEq, Debug)]
2369        pub enum BoxKind {
2370            Inner,
2371            Outer,
2372        }
2373
2374        /// Layout state (mirrors imageflow's Layout).
2375        #[derive(Copy, Clone, PartialEq, Debug)]
2376        pub struct Layout {
2377            pub source: AR,
2378            pub target: AR,
2379            pub canvas: AR,
2380            pub image: AR,
2381        }
2382
2383        impl Layout {
2384            pub fn create(source: AR, target: AR) -> Self {
2385                Layout {
2386                    source,
2387                    target,
2388                    canvas: source,
2389                    image: source,
2390                }
2391            }
2392
2393            fn scale_canvas(self, target: AR, sizing: BoxKind) -> Self {
2394                let new_canvas = self.canvas.box_of(&target, sizing);
2395                Layout {
2396                    image: self.image.distort_with(&self.canvas, &new_canvas),
2397                    canvas: new_canvas,
2398                    ..self
2399                }
2400            }
2401
2402            fn fill_crop(self, target: AR) -> Self {
2403                let new_source = target.box_of(&self.source, BoxKind::Inner);
2404                Layout {
2405                    source: new_source,
2406                    image: target,
2407                    canvas: target,
2408                    ..self
2409                }
2410            }
2411
2412            fn distort_canvas(self, target: AR) -> Self {
2413                Layout {
2414                    image: self.image.distort_with(&self.canvas, &target),
2415                    canvas: target,
2416                    ..self
2417                }
2418            }
2419
2420            fn pad_canvas(self, target: AR) -> Self {
2421                Layout {
2422                    canvas: target,
2423                    ..self
2424                }
2425            }
2426
2427            fn crop(self, target: AR) -> Self {
2428                let new_image = self.image.intersection(&target);
2429                let new_source = new_image.box_of(&self.source, BoxKind::Inner);
2430                Layout {
2431                    source: new_source,
2432                    image: new_image,
2433                    canvas: target,
2434                    ..self
2435                }
2436            }
2437
2438            fn crop_aspect(self) -> Self {
2439                let target_box = self.target.box_of(&self.canvas, BoxKind::Inner);
2440                self.crop(target_box)
2441            }
2442
2443            fn pad_aspect(self) -> Self {
2444                let target_box = self.target.box_of(&self.canvas, BoxKind::Outer);
2445                self.pad_canvas(target_box)
2446            }
2447
2448            fn crop_to_intersection(self) -> Self {
2449                let target = self.image.intersection(&self.target);
2450                self.crop(target)
2451            }
2452
2453            fn evaluate_condition(&self, c: Cond) -> bool {
2454                c.matches(self.canvas.cmp_size(&self.target))
2455            }
2456
2457            pub fn execute_all(self, steps: &[Step]) -> Self {
2458                let mut lay = self;
2459                let mut skipping = false;
2460                for step in steps {
2461                    match *step {
2462                        Step::SkipIf(c) if lay.evaluate_condition(c) => {
2463                            skipping = true;
2464                        }
2465                        Step::SkipUnless(c) if !lay.evaluate_condition(c) => {
2466                            skipping = true;
2467                        }
2468                        Step::BeginSequence => {
2469                            skipping = false;
2470                        }
2471                        _ => {}
2472                    }
2473                    if !skipping {
2474                        lay = lay.execute_step(*step);
2475                    }
2476                }
2477                lay
2478            }
2479
2480            fn execute_step(self, step: Step) -> Self {
2481                match step {
2482                    Step::None | Step::BeginSequence | Step::SkipIf(_) | Step::SkipUnless(_) => {
2483                        self
2484                    }
2485                    Step::ScaleToOuter => self.scale_canvas(self.target, BoxKind::Outer),
2486                    Step::FillCrop => self.fill_crop(self.target),
2487                    Step::ScaleToInner => self.scale_canvas(self.target, BoxKind::Inner),
2488                    Step::PadAspect => self.pad_aspect(),
2489                    Step::Pad => self.pad_canvas(self.target),
2490                    Step::CropAspect => self.crop_aspect(),
2491                    Step::Crop => self.crop(self.target),
2492                    Step::CropToIntersection => self.crop_to_intersection(),
2493                    Step::Distort => self.distort_canvas(self.target),
2494                }
2495            }
2496        }
2497
2498        #[derive(Copy, Clone, PartialEq, Debug)]
2499        pub enum Cond {
2500            Both(Ordering),
2501            Neither(Ordering),
2502            Either(Ordering),
2503            Larger1DSmaller1D,
2504        }
2505
2506        impl Cond {
2507            pub fn matches(&self, cmp: (Ordering, Ordering)) -> bool {
2508                match *self {
2509                    Cond::Both(v) => cmp.0 == v && cmp.1 == v,
2510                    Cond::Neither(v) => cmp.0 != v && cmp.1 != v,
2511                    Cond::Either(v) => cmp.0 == v || cmp.1 == v,
2512                    Cond::Larger1DSmaller1D => {
2513                        cmp == (Ordering::Greater, Ordering::Less)
2514                            || cmp == (Ordering::Less, Ordering::Greater)
2515                    }
2516                }
2517            }
2518        }
2519
2520        #[derive(Copy, Clone, PartialEq, Debug)]
2521        pub enum Step {
2522            None,
2523            BeginSequence,
2524            SkipIf(Cond),
2525            SkipUnless(Cond),
2526            ScaleToOuter,
2527            ScaleToInner,
2528            FillCrop,
2529            Distort,
2530            Pad,
2531            PadAspect,
2532            Crop,
2533            CropAspect,
2534            CropToIntersection,
2535        }
2536
2537        /// Step sequences matching exactly what ir4/layout.rs build_constraints()
2538        /// produces for each (FitMode, ScaleMode) combination that maps to a
2539        /// zenlayout ConstraintMode.
2540        pub fn steps_for_mode(mode: super::ConstraintMode) -> Vec<Step> {
2541            use super::ConstraintMode as CM;
2542
2543            match mode {
2544                // Stretch + Both → distort(target)
2545                CM::Distort => vec![Step::Distort],
2546
2547                // Max + DownscaleOnly → skip_unless(Either(Greater)), scale_to_inner
2548                CM::Within => vec![
2549                    Step::SkipUnless(Cond::Either(Ordering::Greater)),
2550                    Step::ScaleToInner,
2551                ],
2552
2553                // Max + Both → scale_to_inner (always)
2554                CM::Fit => vec![Step::ScaleToInner],
2555
2556                // Crop + Both → scale_to_outer, crop
2557                CM::FitCrop => vec![Step::ScaleToOuter, Step::Crop],
2558
2559                // Crop + DownscaleOnly (from ir4/layout.rs:240-252)
2560                CM::WithinCrop => vec![
2561                    Step::SkipIf(Cond::Either(Ordering::Less)),
2562                    Step::ScaleToOuter,
2563                    Step::Crop,
2564                    Step::BeginSequence,
2565                    Step::SkipUnless(Cond::Larger1DSmaller1D),
2566                    Step::CropToIntersection,
2567                ],
2568
2569                // Pad + Both → scale_to_inner, pad
2570                CM::FitPad => vec![Step::ScaleToInner, Step::Pad],
2571
2572                // Pad + DownscaleOnly (from ir4/layout.rs:197-200)
2573                CM::WithinPad => vec![
2574                    Step::SkipUnless(Cond::Either(Ordering::Greater)),
2575                    Step::ScaleToInner,
2576                    Step::Pad,
2577                ],
2578
2579                // PadWithin = downscale to fit if needed, always pad to target canvas.
2580                // Matches imageflow's Pad+UpscaleCanvas / Max+UpscaleCanvas:
2581                // Seq 1: skip_unless(Either(Greater)), scale_to_inner
2582                // Seq 2: pad (always)
2583                CM::PadWithin => vec![
2584                    Step::SkipUnless(Cond::Either(Ordering::Greater)),
2585                    Step::ScaleToInner,
2586                    Step::BeginSequence,
2587                    Step::Pad,
2588                ],
2589
2590                // AspectCrop → crop_aspect (always)
2591                CM::AspectCrop => vec![Step::CropAspect],
2592            }
2593        }
2594
2595        /// Gravity alignment (center only for parity — imageflow defaults to center).
2596        pub fn gravity_offset(inner: i32, outer: i32) -> i32 {
2597            (outer - inner) / 2
2598        }
2599    }
2600
2601    /// Parity test: run both zenlayout and the oracle, compare results.
2602    fn parity_check(mode: ConstraintMode, source_w: u32, source_h: u32, tw: u32, th: u32) {
2603        // Skip zero dimensions
2604        if source_w == 0 || source_h == 0 || tw == 0 || th == 0 {
2605            return;
2606        }
2607        // Skip overflow-prone sizes
2608        if source_w > 100_000 || source_h > 100_000 || tw > 100_000 || th > 100_000 {
2609            return;
2610        }
2611
2612        // --- Oracle ---
2613        let source = oracle::AR::new(source_w as i32, source_h as i32);
2614        let target = oracle::AR::new(tw as i32, th as i32);
2615        let steps = oracle::steps_for_mode(mode);
2616        let layout = oracle::Layout::create(source, target).execute_all(&steps);
2617
2618        // Oracle results
2619        let oracle_image = Size::new(layout.image.w as u32, layout.image.h as u32);
2620        let oracle_canvas = Size::new(layout.canvas.w as u32, layout.canvas.h as u32);
2621        let oracle_crop = (layout.source.w as u32, layout.source.h as u32);
2622
2623        // --- Zenlayout ---
2624        let zl = match Constraint::new(mode, tw, th).compute(source_w, source_h) {
2625            Ok(l) => l,
2626            Err(_) => return, // zenlayout errors on edge cases oracle might handle differently
2627        };
2628
2629        let zl_image = zl.resize_to;
2630        let zl_canvas = zl.canvas;
2631        let zl_crop = match zl.source_crop {
2632            Some(r) => (r.width, r.height),
2633            None => (source_w, source_h),
2634        };
2635
2636        // Compare
2637        assert_eq!(
2638            zl_image, oracle_image,
2639            "IMAGE mismatch for {mode:?} {source_w}x{source_h} → {tw}x{th}: \
2640             zenlayout={zl_image:?} oracle={oracle_image:?}"
2641        );
2642        assert_eq!(
2643            zl_canvas, oracle_canvas,
2644            "CANVAS mismatch for {mode:?} {source_w}x{source_h} → {tw}x{th}: \
2645             zenlayout={zl_canvas:?} oracle={oracle_canvas:?}"
2646        );
2647        assert_eq!(
2648            zl_crop, oracle_crop,
2649            "CROP mismatch for {mode:?} {source_w}x{source_h} → {tw}x{th}: \
2650             zenlayout={zl_crop:?} oracle={oracle_crop:?}"
2651        );
2652    }
2653
2654    /// Source sizes to test against: a variety of aspect ratios and dimensions.
2655    fn parity_source_sizes() -> Vec<(u32, u32)> {
2656        let mut sizes = Vec::new();
2657        // Fixed interesting sizes
2658        let fixed = [
2659            (1, 1),
2660            (1, 3),
2661            (3, 1),
2662            (2, 4),
2663            (4, 2),
2664            (100, 100),
2665            (1000, 500),
2666            (500, 1000),
2667            (1200, 400),
2668            (400, 1200),
2669            (1399, 697),
2670            (697, 1399),
2671            (1621, 883),
2672            (883, 1621),
2673            (971, 967),
2674            (967, 971),
2675            (5104, 3380),
2676            (3380, 5104),
2677            (638, 423),
2678            (423, 638),
2679            (768, 433),
2680            (433, 768),
2681        ];
2682        sizes.extend_from_slice(&fixed);
2683
2684        // Variations of each target size
2685        for &(tw, th) in &TARGETS {
2686            // Larger than target
2687            sizes.push((tw * 2, th * 2));
2688            sizes.push((tw * 3, th * 2));
2689            sizes.push((tw * 2, th * 3));
2690            sizes.push((tw * 10, th * 10));
2691            // Smaller than target
2692            if tw > 2 && th > 2 {
2693                sizes.push((tw / 2, th / 2));
2694                sizes.push((tw / 3 + 1, th / 2));
2695                sizes.push((tw / 2, th / 3 + 1));
2696            }
2697            // One larger, one smaller (mixed)
2698            sizes.push((tw * 2, th / 2 + 1));
2699            sizes.push((tw / 2 + 1, th * 2));
2700            // Exact match
2701            sizes.push((tw, th));
2702            // Off by one
2703            sizes.push((tw + 1, th + 1));
2704            if tw > 1 && th > 1 {
2705                sizes.push((tw - 1, th - 1));
2706            }
2707        }
2708
2709        sizes.sort();
2710        sizes.dedup();
2711        // Filter out zeros
2712        sizes.retain(|&(w, h)| w > 0 && h > 0);
2713        sizes
2714    }
2715
2716    #[test]
2717    fn parity_distort() {
2718        let sources = parity_source_sizes();
2719        let mut count = 0u64;
2720        for &(tw, th) in &TARGETS {
2721            for &(sw, sh) in &sources {
2722                parity_check(ConstraintMode::Distort, sw, sh, tw, th);
2723                count += 1;
2724            }
2725        }
2726        assert!(count > 500, "Expected >500 combinations, got {count}");
2727    }
2728
2729    #[test]
2730    fn parity_within() {
2731        let sources = parity_source_sizes();
2732        let mut count = 0u64;
2733        for &(tw, th) in &TARGETS {
2734            for &(sw, sh) in &sources {
2735                parity_check(ConstraintMode::Within, sw, sh, tw, th);
2736                count += 1;
2737            }
2738        }
2739        assert!(count > 500, "Expected >500 combinations, got {count}");
2740    }
2741
2742    #[test]
2743    fn parity_fit() {
2744        let sources = parity_source_sizes();
2745        let mut count = 0u64;
2746        for &(tw, th) in &TARGETS {
2747            for &(sw, sh) in &sources {
2748                parity_check(ConstraintMode::Fit, sw, sh, tw, th);
2749                count += 1;
2750            }
2751        }
2752        assert!(count > 500, "Expected >500 combinations, got {count}");
2753    }
2754
2755    #[test]
2756    fn parity_fit_crop() {
2757        let sources = parity_source_sizes();
2758        let mut count = 0u64;
2759        for &(tw, th) in &TARGETS {
2760            for &(sw, sh) in &sources {
2761                parity_check(ConstraintMode::FitCrop, sw, sh, tw, th);
2762                count += 1;
2763            }
2764        }
2765        assert!(count > 500, "Expected >500 combinations, got {count}");
2766    }
2767
2768    #[test]
2769    fn parity_within_crop() {
2770        let sources = parity_source_sizes();
2771        let mut count = 0u64;
2772        for &(tw, th) in &TARGETS {
2773            for &(sw, sh) in &sources {
2774                parity_check(ConstraintMode::WithinCrop, sw, sh, tw, th);
2775                count += 1;
2776            }
2777        }
2778        assert!(count > 500, "Expected >500 combinations, got {count}");
2779    }
2780
2781    #[test]
2782    fn parity_fit_pad() {
2783        let sources = parity_source_sizes();
2784        let mut count = 0u64;
2785        for &(tw, th) in &TARGETS {
2786            for &(sw, sh) in &sources {
2787                parity_check(ConstraintMode::FitPad, sw, sh, tw, th);
2788                count += 1;
2789            }
2790        }
2791        assert!(count > 500, "Expected >500 combinations, got {count}");
2792    }
2793
2794    #[test]
2795    fn parity_within_pad() {
2796        let sources = parity_source_sizes();
2797        let mut count = 0u64;
2798        for &(tw, th) in &TARGETS {
2799            for &(sw, sh) in &sources {
2800                parity_check(ConstraintMode::WithinPad, sw, sh, tw, th);
2801                count += 1;
2802            }
2803        }
2804        assert!(count > 500, "Expected >500 combinations, got {count}");
2805    }
2806
2807    #[test]
2808    fn parity_pad_within() {
2809        let sources = parity_source_sizes();
2810        let mut count = 0u64;
2811        for &(tw, th) in &TARGETS {
2812            for &(sw, sh) in &sources {
2813                parity_check(ConstraintMode::PadWithin, sw, sh, tw, th);
2814                count += 1;
2815            }
2816        }
2817        assert!(count > 500, "Expected >500 combinations, got {count}");
2818    }
2819
2820    #[test]
2821    fn parity_aspect_crop() {
2822        let sources = parity_source_sizes();
2823        let mut count = 0u64;
2824        for &(tw, th) in &TARGETS {
2825            for &(sw, sh) in &sources {
2826                parity_check(ConstraintMode::AspectCrop, sw, sh, tw, th);
2827                count += 1;
2828            }
2829        }
2830        assert!(count > 500, "Expected >500 combinations, got {count}");
2831    }
2832}