Skip to main content

appcore_filemaker/
image.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: image.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded image contracts and behavior for this crate.
12
13use std::io::Cursor;
14
15use image::ImageDecoder as _;
16use rust_decimal::prelude::ToPrimitive as _;
17use rust_decimal::Decimal;
18use serde::{Deserialize, Serialize};
19
20use crate::{Asset, ErrorCode, FileMakerError, Rect, Result, Size, Unit};
21
22/// How image pixels are mapped into an element box.
23#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ImageFit {
26    /// Preserve aspect ratio and fit entirely inside the box.
27    #[default]
28    Contain,
29    /// Preserve aspect ratio and crop to cover the box.
30    Cover,
31    /// Stretch the selected pixels to the complete box.
32    Fill,
33    /// Paint at the intrinsic 96-DPI CSS size without scaling.
34    None,
35    /// Use `none` when pixels fit, otherwise `contain`.
36    ScaleDown,
37}
38
39/// Fractional crop insets expressed in parts per million.
40#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
41#[serde(default, deny_unknown_fields)]
42pub struct ImageCrop {
43    pub left: u32,
44    pub top: u32,
45    pub right: u32,
46    pub bottom: u32,
47}
48
49/// Source-independent image paint options.
50#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
51#[serde(default, deny_unknown_fields)]
52pub struct ImageOptions {
53    pub fit: ImageFit,
54    /// Horizontal focal point in parts per million.
55    pub focal_x: u32,
56    /// Vertical focal point in parts per million.
57    pub focal_y: u32,
58    pub crop: ImageCrop,
59    /// Apply raster EXIF orientation before crop and fit.
60    pub respect_exif: bool,
61}
62
63impl Default for ImageOptions {
64    fn default() -> Self {
65        Self {
66            fit: ImageFit::Contain,
67            focal_x: 500_000,
68            focal_y: 500_000,
69            crop: ImageCrop::default(),
70            respect_exif: true,
71        }
72    }
73}
74
75impl ImageOptions {
76    pub fn validate(self) -> Result<()> {
77        if self.focal_x > 1_000_000
78            || self.focal_y > 1_000_000
79            || self.crop.left > 1_000_000
80            || self.crop.top > 1_000_000
81            || self.crop.right > 1_000_000
82            || self.crop.bottom > 1_000_000
83            || self.crop.left.saturating_add(self.crop.right) >= 1_000_000
84            || self.crop.top.saturating_add(self.crop.bottom) >= 1_000_000
85        {
86            return Err(image_error(
87                "image crop/focal values must be valid ppm fractions",
88            ));
89        }
90        Ok(())
91    }
92}
93
94/// Orientation normalized from raster EXIF metadata.
95#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum ImageOrientation {
98    #[default]
99    Identity,
100    Rotate90,
101    Rotate180,
102    Rotate270,
103    FlipHorizontal,
104    FlipVertical,
105    Rotate90FlipHorizontal,
106    Rotate270FlipHorizontal,
107}
108
109impl ImageOrientation {
110    #[must_use]
111    pub fn swaps_dimensions(self) -> bool {
112        matches!(
113            self,
114            Self::Rotate90
115                | Self::Rotate270
116                | Self::Rotate90FlipHorizontal
117                | Self::Rotate270FlipHorizontal
118        )
119    }
120
121    pub(crate) fn apply(self, image: &mut image::DynamicImage) {
122        image.apply_orientation(match self {
123            Self::Identity => image::metadata::Orientation::NoTransforms,
124            Self::Rotate90 => image::metadata::Orientation::Rotate90,
125            Self::Rotate180 => image::metadata::Orientation::Rotate180,
126            Self::Rotate270 => image::metadata::Orientation::Rotate270,
127            Self::FlipHorizontal => image::metadata::Orientation::FlipHorizontal,
128            Self::FlipVertical => image::metadata::Orientation::FlipVertical,
129            Self::Rotate90FlipHorizontal => image::metadata::Orientation::Rotate90FlipH,
130            Self::Rotate270FlipHorizontal => image::metadata::Orientation::Rotate270FlipH,
131        });
132    }
133}
134
135/// Integer source pixel rectangle.
136#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
137pub struct PixelRect {
138    pub x: u32,
139    pub y: u32,
140    pub width: u32,
141    pub height: u32,
142}
143
144/// Fully resolved image paint geometry consumed by exporters.
145#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
146pub struct ImagePlacement {
147    pub source: PixelRect,
148    pub intrinsic_width: u32,
149    pub intrinsic_height: u32,
150    pub destination: Rect,
151    pub clip: Rect,
152    pub orientation: ImageOrientation,
153    pub vector: bool,
154}
155
156/// Reads bounded metadata and resolves crop/fit geometry before export.
157pub fn resolve_image_placement(
158    asset: &Asset,
159    bounds: Rect,
160    options: ImageOptions,
161    max_pixels: u64,
162) -> Result<ImagePlacement> {
163    options.validate()?;
164    if bounds.size.width <= Unit::ZERO || bounds.size.height <= Unit::ZERO {
165        return Err(image_error("image destination dimensions must be positive"));
166    }
167    bounds.right()?;
168    bounds.bottom()?;
169    let (mut width, mut height, orientation, vector) = image_metadata(asset, options.respect_exif)?;
170    if u64::from(width) * u64::from(height) > max_pixels {
171        return Err(FileMakerError::new(
172            ErrorCode::LimitExceeded,
173            "image pixel count exceeds configured limit",
174        ));
175    }
176    if orientation.swaps_dimensions() {
177        std::mem::swap(&mut width, &mut height);
178    }
179    let mut source = crop_rect(width, height, options.crop)?;
180    let destination = match options.fit {
181        ImageFit::Fill => bounds,
182        ImageFit::Contain => contain(bounds, source.width, source.height)?,
183        ImageFit::Cover => {
184            source = cover_crop(source, bounds.size, options.focal_x, options.focal_y)?;
185            bounds
186        }
187        ImageFit::None => intrinsic_destination(bounds, source.width, source.height)?,
188        ImageFit::ScaleDown => {
189            let intrinsic = intrinsic_destination(bounds, source.width, source.height)?;
190            if intrinsic.size.width <= bounds.size.width
191                && intrinsic.size.height <= bounds.size.height
192            {
193                intrinsic
194            } else {
195                contain(bounds, source.width, source.height)?
196            }
197        }
198    };
199    Ok(ImagePlacement {
200        source,
201        intrinsic_width: width,
202        intrinsic_height: height,
203        destination,
204        clip: bounds,
205        orientation,
206        vector,
207    })
208}
209
210fn image_metadata(asset: &Asset, respect_exif: bool) -> Result<(u32, u32, ImageOrientation, bool)> {
211    if asset.media_type == "image/svg+xml" {
212        let (width, height) = svg_dimensions(&asset.bytes)?;
213        return Ok((width, height, ImageOrientation::Identity, true));
214    }
215    let reader = image::ImageReader::new(Cursor::new(&asset.bytes))
216        .with_guessed_format()
217        .map_err(|error| image_error(format!("cannot identify image: {error}")))?;
218    let mut decoder = reader
219        .into_decoder()
220        .map_err(|error| image_error(format!("cannot read image metadata: {error}")))?;
221    let (width, height) = decoder.dimensions();
222    if width == 0 || height == 0 {
223        return Err(image_error("image dimensions must be non-zero"));
224    }
225    let orientation = if respect_exif {
226        decoder
227            .orientation()
228            .map(ImageOrientation::from)
229            .map_err(|error| image_error(format!("cannot read image orientation: {error}")))?
230    } else {
231        ImageOrientation::Identity
232    };
233    Ok((width, height, orientation, false))
234}
235
236impl From<image::metadata::Orientation> for ImageOrientation {
237    fn from(value: image::metadata::Orientation) -> Self {
238        match value {
239            image::metadata::Orientation::NoTransforms => Self::Identity,
240            image::metadata::Orientation::Rotate90 => Self::Rotate90,
241            image::metadata::Orientation::Rotate180 => Self::Rotate180,
242            image::metadata::Orientation::Rotate270 => Self::Rotate270,
243            image::metadata::Orientation::FlipHorizontal => Self::FlipHorizontal,
244            image::metadata::Orientation::FlipVertical => Self::FlipVertical,
245            image::metadata::Orientation::Rotate90FlipH => Self::Rotate90FlipHorizontal,
246            image::metadata::Orientation::Rotate270FlipH => Self::Rotate270FlipHorizontal,
247        }
248    }
249}
250
251fn crop_rect(width: u32, height: u32, crop: ImageCrop) -> Result<PixelRect> {
252    let scale = |value: u32, fraction: u32| -> u32 {
253        (u64::from(value) * u64::from(fraction) / 1_000_000).min(u64::from(u32::MAX)) as u32
254    };
255    let x = scale(width, crop.left);
256    let y = scale(height, crop.top);
257    let right = scale(width, crop.right);
258    let bottom = scale(height, crop.bottom);
259    let width = width.saturating_sub(x).saturating_sub(right);
260    let height = height.saturating_sub(y).saturating_sub(bottom);
261    if width == 0 || height == 0 {
262        return Err(image_error("image crop produced an empty source"));
263    }
264    Ok(PixelRect {
265        x,
266        y,
267        width,
268        height,
269    })
270}
271
272fn contain(bounds: Rect, width: u32, height: u32) -> Result<Rect> {
273    let by_width = scale_unit_ratio(bounds.size.width, height, width)?;
274    let size = if by_width <= bounds.size.height {
275        Size::new(bounds.size.width, by_width)?
276    } else {
277        Size::new(
278            scale_unit_ratio(bounds.size.height, width, height)?,
279            bounds.size.height,
280        )?
281    };
282    centered(bounds, size)
283}
284
285fn scale_unit_ratio(value: Unit, numerator: u32, denominator: u32) -> Result<Unit> {
286    if denominator == 0 {
287        return Err(image_error("image aspect-ratio denominator is zero"));
288    }
289    let denominator = i128::from(denominator);
290    let raw = i128::from(value.raw())
291        .checked_mul(i128::from(numerator))
292        .and_then(|scaled| scaled.checked_add(denominator / 2))
293        .ok_or_else(|| image_error("image aspect-ratio calculation overflow"))?
294        / denominator;
295    Ok(Unit::from_raw(i64::try_from(raw).map_err(|_| {
296        image_error("image aspect-ratio result exceeds supported range")
297    })?))
298}
299
300fn intrinsic_destination(bounds: Rect, width: u32, height: u32) -> Result<Rect> {
301    let size = Size::new(
302        Unit::from_ratio(i128::from(width) * 3, 4)?,
303        Unit::from_ratio(i128::from(height) * 3, 4)?,
304    )?;
305    centered(bounds, size)
306}
307
308fn centered(bounds: Rect, size: Size) -> Result<Rect> {
309    Rect::new(
310        bounds.origin.x.checked_add(Unit::from_raw(
311            bounds.size.width.checked_sub(size.width)?.raw() / 2,
312        ))?,
313        bounds.origin.y.checked_add(Unit::from_raw(
314            bounds.size.height.checked_sub(size.height)?.raw() / 2,
315        ))?,
316        size.width,
317        size.height,
318    )
319}
320
321fn cover_crop(
322    mut source: PixelRect,
323    target: Size,
324    focal_x: u32,
325    focal_y: u32,
326) -> Result<PixelRect> {
327    let target_height = u128::try_from(target.height.raw())
328        .map_err(|_| image_error("image cover target height is invalid"))?;
329    let target_width = u128::try_from(target.width.raw())
330        .map_err(|_| image_error("image cover target width is invalid"))?;
331    if target_height == 0 || target_width == 0 {
332        return Err(image_error(
333            "image cover target dimensions must be positive",
334        ));
335    }
336    let source_ratio = u128::from(source.width) * target_height;
337    let target_ratio = u128::from(source.height) * target_width;
338    if source_ratio > target_ratio {
339        let width = u32::try_from(
340            i128::from(source.height) * i128::from(target.width.raw())
341                / i128::from(target.height.raw()),
342        )
343        .map_err(|_| image_error("image cover width overflow"))?;
344        source.x = source
345            .x
346            .saturating_add(focal_offset(source.width, width, focal_x));
347        source.width = width.max(1);
348    } else if source_ratio < target_ratio {
349        let height = u32::try_from(
350            i128::from(source.width) * i128::from(target.height.raw())
351                / i128::from(target.width.raw()),
352        )
353        .map_err(|_| image_error("image cover height overflow"))?;
354        source.y = source
355            .y
356            .saturating_add(focal_offset(source.height, height, focal_y));
357        source.height = height.max(1);
358    }
359    Ok(source)
360}
361
362fn focal_offset(full: u32, selected: u32, focal: u32) -> u32 {
363    let center = u64::from(full) * u64::from(focal) / 1_000_000;
364    let desired = center.saturating_sub(u64::from(selected) / 2);
365    desired.min(u64::from(full.saturating_sub(selected))) as u32
366}
367
368fn svg_dimensions(bytes: &[u8]) -> Result<(u32, u32)> {
369    let text = std::str::from_utf8(bytes).map_err(|_| image_error("SVG is not UTF-8"))?;
370    let marker = "viewBox=";
371    let start = text
372        .find(marker)
373        .ok_or_else(|| image_error("SVG requires a viewBox"))?
374        + marker.len();
375    let quote = text
376        .as_bytes()
377        .get(start)
378        .copied()
379        .ok_or_else(|| image_error("invalid SVG viewBox"))?;
380    if !matches!(quote, b'\'' | b'"') {
381        return Err(image_error("SVG viewBox must be quoted"));
382    }
383    let rest = &text[start + 1..];
384    let end = rest
385        .find(char::from(quote))
386        .ok_or_else(|| image_error("unterminated SVG viewBox"))?;
387    let values = rest[..end]
388        .split(|character: char| character.is_ascii_whitespace() || character == ',')
389        .filter(|value| !value.is_empty())
390        .map(str::parse::<Decimal>)
391        .collect::<std::result::Result<Vec<_>, _>>()
392        .map_err(|_| image_error("invalid SVG viewBox number"))?;
393    if values.len() != 4 || values[2] <= Decimal::ZERO || values[3] <= Decimal::ZERO {
394        return Err(image_error("SVG viewBox must contain four valid numbers"));
395    }
396    let width = values[2]
397        .ceil()
398        .to_u32()
399        .ok_or_else(|| image_error("SVG viewBox width exceeds supported range"))?;
400    let height = values[3]
401        .ceil()
402        .to_u32()
403        .ok_or_else(|| image_error("SVG viewBox height exceeds supported range"))?;
404    Ok((width, height))
405}
406
407fn image_error(message: impl Into<String>) -> FileMakerError {
408    FileMakerError::new(ErrorCode::AssetInvalid, message)
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn cover_uses_focal_point_and_exact_target() {
417        let asset = Asset::new(
418            "vector.svg",
419            "image/svg+xml",
420            br#"<svg viewBox="0 0 200 100"></svg>"#.to_vec(),
421        );
422        let bounds = Rect::new(
423            Unit::ZERO,
424            Unit::ZERO,
425            Unit::points(50).unwrap(),
426            Unit::points(50).unwrap(),
427        )
428        .unwrap();
429        let placement = resolve_image_placement(
430            &asset,
431            bounds,
432            ImageOptions {
433                fit: ImageFit::Cover,
434                focal_x: 1_000_000,
435                ..ImageOptions::default()
436            },
437            1_000_000,
438        )
439        .unwrap();
440        assert_eq!(
441            placement.source,
442            PixelRect {
443                x: 100,
444                y: 0,
445                width: 100,
446                height: 100
447            }
448        );
449        assert_eq!(placement.destination, bounds);
450    }
451}