1use 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ImageFit {
26 #[default]
28 Contain,
29 Cover,
31 Fill,
33 None,
35 ScaleDown,
37}
38
39#[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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
51#[serde(default, deny_unknown_fields)]
52pub struct ImageOptions {
53 pub fit: ImageFit,
54 pub focal_x: u32,
56 pub focal_y: u32,
58 pub crop: ImageCrop,
59 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#[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#[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#[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
156pub 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}