1use std::error::Error;
4use std::fmt;
5use std::str::FromStr;
6use std::time::Duration;
7
8pub const MAX_OUTPUT_PIXELS: u64 = 67_108_864;
17
18pub const MAX_DECODED_PIXELS: u64 = 100_000_000;
27
28pub const MAX_WATERMARK_PIXELS: u64 = 4_000_000;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub struct Dimensions {
52 pub width: u32,
53 pub height: u32,
54}
55
56impl Dimensions {
57 pub const fn new(width: u32, height: u32) -> Self {
59 Self { width, height }
60 }
61
62 pub const fn pixel_count(self) -> u64 {
64 self.width as u64 * self.height as u64
65 }
66}
67
68impl fmt::Display for Dimensions {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 write!(f, "{}x{}", self.width, self.height)
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct RawArtifact {
89 pub bytes: Vec<u8>,
91 pub declared_media_type: Option<MediaType>,
93}
94
95impl RawArtifact {
96 pub fn new(bytes: Vec<u8>, declared_media_type: Option<MediaType>) -> Self {
98 Self {
99 bytes,
100 declared_media_type,
101 }
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct Artifact {
122 pub bytes: Vec<u8>,
124 pub media_type: MediaType,
126 pub metadata: ArtifactMetadata,
128}
129
130impl Artifact {
131 pub fn new(bytes: Vec<u8>, media_type: MediaType, metadata: ArtifactMetadata) -> Self {
133 Self {
134 bytes,
135 media_type,
136 metadata,
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct ArtifactMetadata {
162 pub width: Option<u32>,
164 pub height: Option<u32>,
166 pub frame_count: u32,
168 pub duration: Option<Duration>,
170 pub has_alpha: Option<bool>,
172}
173
174impl ArtifactMetadata {
175 pub fn dimensions(&self) -> Option<Dimensions> {
177 match (self.width, self.height) {
178 (Some(w), Some(h)) => Some(Dimensions::new(w, h)),
179 _ => None,
180 }
181 }
182}
183
184impl Default for ArtifactMetadata {
185 fn default() -> Self {
186 Self {
187 width: None,
188 height: None,
189 frame_count: 1,
190 duration: None,
191 has_alpha: None,
192 }
193 }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub enum MediaType {
216 Jpeg,
218 Png,
220 Webp,
222 Avif,
224 Svg,
226 Bmp,
228 Tiff,
230}
231
232impl MediaType {
233 pub const fn as_name(self) -> &'static str {
235 match self {
236 Self::Jpeg => "jpeg",
237 Self::Png => "png",
238 Self::Webp => "webp",
239 Self::Avif => "avif",
240 Self::Svg => "svg",
241 Self::Bmp => "bmp",
242 Self::Tiff => "tiff",
243 }
244 }
245
246 pub const fn as_mime(self) -> &'static str {
248 match self {
249 Self::Jpeg => "image/jpeg",
250 Self::Png => "image/png",
251 Self::Webp => "image/webp",
252 Self::Avif => "image/avif",
253 Self::Svg => "image/svg+xml",
254 Self::Bmp => "image/bmp",
255 Self::Tiff => "image/tiff",
256 }
257 }
258
259 pub const fn is_lossy(self) -> bool {
261 matches!(self, Self::Jpeg | Self::Webp | Self::Avif)
262 }
263
264 pub const fn is_raster(self) -> bool {
266 !matches!(self, Self::Svg)
267 }
268}
269
270impl fmt::Display for MediaType {
271 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272 f.write_str(self.as_mime())
273 }
274}
275
276impl FromStr for MediaType {
277 type Err = String;
278
279 fn from_str(value: &str) -> Result<Self, Self::Err> {
280 match value {
281 "jpeg" | "jpg" => Ok(Self::Jpeg),
282 "png" => Ok(Self::Png),
283 "webp" => Ok(Self::Webp),
284 "avif" => Ok(Self::Avif),
285 "svg" => Ok(Self::Svg),
286 "bmp" => Ok(Self::Bmp),
287 "tiff" | "tif" => Ok(Self::Tiff),
288 _ => Err(format!("unsupported media type `{value}`")),
289 }
290 }
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct WatermarkInput {
311 pub image: Artifact,
313 pub position: Position,
315 pub opacity: u8,
317 pub margin: u32,
319}
320
321#[derive(Debug, Clone, PartialEq)]
333pub struct TransformRequest {
334 pub input: Artifact,
336 pub options: TransformOptions,
338 pub watermark: Option<WatermarkInput>,
340}
341
342impl TransformRequest {
343 pub fn new(input: Artifact, options: TransformOptions) -> Self {
345 Self {
346 input,
347 options,
348 watermark: None,
349 }
350 }
351
352 pub fn with_watermark(
354 input: Artifact,
355 options: TransformOptions,
356 watermark: WatermarkInput,
357 ) -> Self {
358 Self {
359 input,
360 options,
361 watermark: Some(watermark),
362 }
363 }
364
365 pub fn normalize(self) -> Result<NormalizedTransformRequest, TransformError> {
367 let options = self.options.normalize(self.input.media_type)?;
368
369 if let Some(ref wm) = self.watermark {
370 validate_watermark(wm)?;
371 }
372
373 Ok(NormalizedTransformRequest {
374 input: self.input,
375 options,
376 watermark: self.watermark,
377 })
378 }
379}
380
381#[derive(Debug, Clone, PartialEq)]
383pub struct NormalizedTransformRequest {
384 pub input: Artifact,
386 pub options: NormalizedTransformOptions,
388 pub watermark: Option<WatermarkInput>,
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415pub struct CropRegion {
416 pub x: u32,
418 pub y: u32,
420 pub width: u32,
422 pub height: u32,
424}
425
426impl FromStr for CropRegion {
427 type Err = String;
428
429 fn from_str(s: &str) -> Result<Self, Self::Err> {
430 let parts: Vec<&str> = s.split(',').collect();
431 if parts.len() != 4 {
432 return Err(format!(
433 "crop must be x,y,w,h (four comma-separated integers), got '{s}'"
434 ));
435 }
436 let x = parts[0]
437 .parse::<u32>()
438 .map_err(|_| format!("crop x must be a non-negative integer, got '{}'", parts[0]))?;
439 let y = parts[1]
440 .parse::<u32>()
441 .map_err(|_| format!("crop y must be a non-negative integer, got '{}'", parts[1]))?;
442 let width = parts[2].parse::<u32>().map_err(|_| {
443 format!(
444 "crop width must be a non-negative integer, got '{}'",
445 parts[2]
446 )
447 })?;
448 let height = parts[3].parse::<u32>().map_err(|_| {
449 format!(
450 "crop height must be a non-negative integer, got '{}'",
451 parts[3]
452 )
453 })?;
454 if width == 0 || height == 0 {
455 return Err("crop width and height must be greater than zero".to_string());
456 }
457 Ok(CropRegion {
458 x,
459 y,
460 width,
461 height,
462 })
463 }
464}
465
466impl fmt::Display for CropRegion {
467 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
468 write!(f, "{},{},{},{}", self.x, self.y, self.width, self.height)
469 }
470}
471
472#[derive(Debug, Clone, PartialEq)]
497pub struct TransformOptions {
498 pub width: Option<u32>,
500 pub height: Option<u32>,
502 pub fit: Option<Fit>,
504 pub position: Option<Position>,
506 pub format: Option<MediaType>,
508 pub quality: Option<u8>,
510 pub background: Option<Rgba8>,
512 pub rotate: Rotation,
514 pub auto_orient: bool,
516 pub strip_metadata: bool,
518 pub preserve_exif: bool,
520 pub blur: Option<f32>,
525 pub sharpen: Option<f32>,
531 pub crop: Option<CropRegion>,
537 pub deadline: Option<Duration>,
544}
545
546impl Default for TransformOptions {
547 fn default() -> Self {
548 Self {
549 width: None,
550 height: None,
551 fit: None,
552 position: None,
553 format: None,
554 quality: None,
555 background: None,
556 rotate: Rotation::Deg0,
557 auto_orient: true,
558 strip_metadata: true,
559 preserve_exif: false,
560 blur: None,
561 sharpen: None,
562 crop: None,
563 deadline: None,
564 }
565 }
566}
567
568impl TransformOptions {
569 pub fn normalize(
571 self,
572 input_media_type: MediaType,
573 ) -> Result<NormalizedTransformOptions, TransformError> {
574 validate_dimension("width", self.width)?;
575 validate_dimension("height", self.height)?;
576 validate_quality(self.quality)?;
577 validate_blur(self.blur)?;
578 validate_sharpen(self.sharpen)?;
579 if let Some(crop) = self.crop
580 && (crop.width == 0 || crop.height == 0)
581 {
582 return Err(TransformError::InvalidOptions(
583 "crop width and height must be greater than zero".to_string(),
584 ));
585 }
586
587 let has_bounded_resize = self.width.is_some() && self.height.is_some();
588
589 if self.fit.is_some() && !has_bounded_resize {
590 return Err(TransformError::InvalidOptions(
591 "fit requires both width and height".to_string(),
592 ));
593 }
594
595 if self.position.is_some() && !has_bounded_resize {
596 return Err(TransformError::InvalidOptions(
597 "position requires both width and height".to_string(),
598 ));
599 }
600
601 if self.preserve_exif && self.strip_metadata {
602 return Err(TransformError::InvalidOptions(
603 "preserve_exif requires strip_metadata to be false".to_string(),
604 ));
605 }
606
607 let format = self.format.unwrap_or(input_media_type);
608
609 if self.preserve_exif && format == MediaType::Svg {
610 return Err(TransformError::InvalidOptions(
611 "preserveExif is not supported with SVG output".to_string(),
612 ));
613 }
614
615 if self.quality.is_some() && !format.is_lossy() {
616 return Err(TransformError::InvalidOptions(
617 "quality requires a lossy output format".to_string(),
618 ));
619 }
620
621 let fit = if has_bounded_resize {
622 Some(self.fit.unwrap_or(Fit::Contain))
623 } else {
624 None
625 };
626
627 Ok(NormalizedTransformOptions {
628 width: self.width,
629 height: self.height,
630 fit,
631 position: self.position.unwrap_or(Position::Center),
632 format,
633 quality: self.quality,
634 background: self.background,
635 rotate: self.rotate,
636 auto_orient: self.auto_orient,
637 metadata_policy: normalize_metadata_policy(self.strip_metadata, self.preserve_exif),
638 blur: self.blur,
639 sharpen: self.sharpen,
640 crop: self.crop,
641 deadline: self.deadline,
642 })
643 }
644}
645
646#[derive(Debug, Clone, PartialEq)]
648pub struct NormalizedTransformOptions {
649 pub width: Option<u32>,
651 pub height: Option<u32>,
653 pub fit: Option<Fit>,
655 pub position: Position,
657 pub format: MediaType,
659 pub quality: Option<u8>,
661 pub background: Option<Rgba8>,
663 pub rotate: Rotation,
665 pub auto_orient: bool,
667 pub metadata_policy: MetadataPolicy,
669 pub blur: Option<f32>,
671 pub sharpen: Option<f32>,
673 pub crop: Option<CropRegion>,
675 pub deadline: Option<Duration>,
677}
678
679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
694pub enum Fit {
695 Contain,
697 Cover,
699 Fill,
701 Inside,
703}
704
705impl Fit {
706 pub const fn as_name(self) -> &'static str {
708 match self {
709 Self::Contain => "contain",
710 Self::Cover => "cover",
711 Self::Fill => "fill",
712 Self::Inside => "inside",
713 }
714 }
715}
716
717impl FromStr for Fit {
718 type Err = String;
719
720 fn from_str(value: &str) -> Result<Self, Self::Err> {
721 match value {
722 "contain" => Ok(Self::Contain),
723 "cover" => Ok(Self::Cover),
724 "fill" => Ok(Self::Fill),
725 "inside" => Ok(Self::Inside),
726 _ => Err(format!("unsupported fit mode `{value}`")),
727 }
728 }
729}
730
731#[derive(Debug, Clone, Copy, PartialEq, Eq)]
746pub enum Position {
747 Center,
749 Top,
751 Right,
753 Bottom,
755 Left,
757 TopLeft,
759 TopRight,
761 BottomLeft,
763 BottomRight,
765}
766
767impl Position {
768 pub const fn as_name(self) -> &'static str {
770 match self {
771 Self::Center => "center",
772 Self::Top => "top",
773 Self::Right => "right",
774 Self::Bottom => "bottom",
775 Self::Left => "left",
776 Self::TopLeft => "top-left",
777 Self::TopRight => "top-right",
778 Self::BottomLeft => "bottom-left",
779 Self::BottomRight => "bottom-right",
780 }
781 }
782}
783
784impl FromStr for Position {
785 type Err = String;
786
787 fn from_str(value: &str) -> Result<Self, Self::Err> {
788 match value {
789 "center" => Ok(Self::Center),
790 "top" => Ok(Self::Top),
791 "right" => Ok(Self::Right),
792 "bottom" => Ok(Self::Bottom),
793 "left" => Ok(Self::Left),
794 "top-left" => Ok(Self::TopLeft),
795 "top-right" => Ok(Self::TopRight),
796 "bottom-left" => Ok(Self::BottomLeft),
797 "bottom-right" => Ok(Self::BottomRight),
798 _ => Err(format!("unsupported position `{value}`")),
799 }
800 }
801}
802
803#[derive(Debug, Clone, Copy, PartialEq, Eq)]
819pub enum Rotation {
820 Deg0,
822 Deg90,
824 Deg180,
826 Deg270,
828}
829
830impl Rotation {
831 pub const fn as_degrees(self) -> u16 {
833 match self {
834 Self::Deg0 => 0,
835 Self::Deg90 => 90,
836 Self::Deg180 => 180,
837 Self::Deg270 => 270,
838 }
839 }
840}
841
842impl FromStr for Rotation {
843 type Err = String;
844
845 fn from_str(value: &str) -> Result<Self, Self::Err> {
846 match value {
847 "0" => Ok(Self::Deg0),
848 "90" => Ok(Self::Deg90),
849 "180" => Ok(Self::Deg180),
850 "270" => Ok(Self::Deg270),
851 _ => Err(format!("unsupported rotation `{value}`")),
852 }
853 }
854}
855
856#[derive(Debug, Clone, Copy, PartialEq, Eq)]
875pub struct Rgba8 {
876 pub r: u8,
878 pub g: u8,
880 pub b: u8,
882 pub a: u8,
884}
885
886impl Rgba8 {
887 pub fn from_hex(value: &str) -> Result<Self, String> {
889 if value.len() != 6 && value.len() != 8 {
890 return Err(format!("unsupported color `{value}`"));
891 }
892
893 let r = u8::from_str_radix(&value[0..2], 16)
894 .map_err(|_| format!("unsupported color `{value}`"))?;
895 let g = u8::from_str_radix(&value[2..4], 16)
896 .map_err(|_| format!("unsupported color `{value}`"))?;
897 let b = u8::from_str_radix(&value[4..6], 16)
898 .map_err(|_| format!("unsupported color `{value}`"))?;
899 let a = if value.len() == 8 {
900 u8::from_str_radix(&value[6..8], 16)
901 .map_err(|_| format!("unsupported color `{value}`"))?
902 } else {
903 u8::MAX
904 };
905
906 Ok(Self { r, g, b, a })
907 }
908}
909
910#[derive(Debug, Clone, Copy, PartialEq, Eq)]
928pub enum MetadataPolicy {
929 StripAll,
931 KeepAll,
933 PreserveExif,
935}
936
937pub fn resolve_metadata_flags(
980 strip: Option<bool>,
981 keep: Option<bool>,
982 preserve_exif: Option<bool>,
983) -> Result<(bool, bool), TransformError> {
984 let keep = keep.unwrap_or(false);
985 let preserve_exif = preserve_exif.unwrap_or(false);
986
987 if keep && preserve_exif {
988 return Err(TransformError::InvalidOptions(
989 "keepMetadata and preserveExif cannot both be true".to_string(),
990 ));
991 }
992
993 let strip_metadata = if keep || preserve_exif {
994 false
995 } else {
996 strip.unwrap_or(true)
997 };
998
999 Ok((strip_metadata, preserve_exif))
1000}
1001
1002#[derive(Debug, Clone, PartialEq, Eq)]
1019pub enum TransformError {
1020 InvalidInput(String),
1022 InvalidOptions(String),
1024 UnsupportedInputMediaType(String),
1026 UnsupportedOutputMediaType(MediaType),
1028 DecodeFailed(String),
1030 EncodeFailed(String),
1032 CapabilityMissing(String),
1034 LimitExceeded(String),
1036}
1037
1038impl fmt::Display for TransformError {
1039 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1040 match self {
1041 Self::InvalidInput(reason) => write!(f, "invalid input: {reason}"),
1042 Self::InvalidOptions(reason) => write!(f, "invalid transform options: {reason}"),
1043 Self::UnsupportedInputMediaType(reason) => {
1044 write!(f, "unsupported input media type: {reason}")
1045 }
1046 Self::UnsupportedOutputMediaType(media_type) => {
1047 write!(f, "unsupported output media type: {media_type}")
1048 }
1049 Self::DecodeFailed(reason) => write!(f, "decode failed: {reason}"),
1050 Self::EncodeFailed(reason) => write!(f, "encode failed: {reason}"),
1051 Self::CapabilityMissing(reason) => write!(f, "missing capability: {reason}"),
1052 Self::LimitExceeded(reason) => write!(f, "limit exceeded: {reason}"),
1053 }
1054 }
1055}
1056
1057impl Error for TransformError {}
1058
1059#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1073pub enum MetadataKind {
1074 Xmp,
1076 Iptc,
1078 Exif,
1080 Icc,
1082}
1083
1084impl fmt::Display for MetadataKind {
1085 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1086 match self {
1087 Self::Xmp => f.write_str("XMP"),
1088 Self::Iptc => f.write_str("IPTC"),
1089 Self::Exif => f.write_str("EXIF"),
1090 Self::Icc => f.write_str("ICC profile"),
1091 }
1092 }
1093}
1094
1095#[derive(Debug, Clone, PartialEq, Eq)]
1111pub enum TransformWarning {
1112 MetadataDropped(MetadataKind),
1115}
1116
1117impl fmt::Display for TransformWarning {
1118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1119 match self {
1120 Self::MetadataDropped(kind) => write!(
1121 f,
1122 "{kind} metadata was present in the input but could not be preserved by the output encoder"
1123 ),
1124 }
1125 }
1126}
1127
1128#[derive(Debug)]
1133pub struct TransformResult {
1134 pub artifact: Artifact,
1136 pub warnings: Vec<TransformWarning>,
1138}
1139
1140pub fn sniff_artifact(input: RawArtifact) -> Result<Artifact, TransformError> {
1196 let (media_type, metadata) = detect_artifact(&input.bytes)?;
1197
1198 if let Some(declared_media_type) = input.declared_media_type
1199 && declared_media_type != media_type
1200 {
1201 return Err(TransformError::InvalidInput(
1202 "declared media type does not match detected media type".to_string(),
1203 ));
1204 }
1205
1206 Ok(Artifact::new(input.bytes, media_type, metadata))
1207}
1208
1209fn validate_dimension(name: &str, value: Option<u32>) -> Result<(), TransformError> {
1210 if matches!(value, Some(0)) {
1211 return Err(TransformError::InvalidOptions(format!(
1212 "{name} must be greater than zero"
1213 )));
1214 }
1215
1216 Ok(())
1217}
1218
1219fn validate_quality(value: Option<u8>) -> Result<(), TransformError> {
1220 if matches!(value, Some(0) | Some(101..=u8::MAX)) {
1221 return Err(TransformError::InvalidOptions(
1222 "quality must be between 1 and 100".to_string(),
1223 ));
1224 }
1225
1226 Ok(())
1227}
1228
1229fn validate_blur(value: Option<f32>) -> Result<(), TransformError> {
1230 if let Some(sigma) = value
1231 && !(0.1..=100.0).contains(&sigma)
1232 {
1233 return Err(TransformError::InvalidOptions(
1234 "blur sigma must be between 0.1 and 100.0".to_string(),
1235 ));
1236 }
1237
1238 Ok(())
1239}
1240
1241fn validate_sharpen(value: Option<f32>) -> Result<(), TransformError> {
1242 if let Some(sigma) = value
1243 && !(0.1..=100.0).contains(&sigma)
1244 {
1245 return Err(TransformError::InvalidOptions(
1246 "sharpen sigma must be between 0.1 and 100.0".to_string(),
1247 ));
1248 }
1249
1250 Ok(())
1251}
1252
1253fn validate_watermark(wm: &WatermarkInput) -> Result<(), TransformError> {
1254 if wm.opacity == 0 || wm.opacity > 100 {
1255 return Err(TransformError::InvalidOptions(
1256 "watermark opacity must be between 1 and 100".to_string(),
1257 ));
1258 }
1259
1260 if !wm.image.media_type.is_raster() {
1261 return Err(TransformError::InvalidOptions(
1262 "watermark image must be a raster format".to_string(),
1263 ));
1264 }
1265
1266 Ok(())
1267}
1268
1269fn normalize_metadata_policy(strip_metadata: bool, preserve_exif: bool) -> MetadataPolicy {
1270 if preserve_exif {
1271 MetadataPolicy::PreserveExif
1272 } else if strip_metadata {
1273 MetadataPolicy::StripAll
1274 } else {
1275 MetadataPolicy::KeepAll
1276 }
1277}
1278
1279fn detect_artifact(bytes: &[u8]) -> Result<(MediaType, ArtifactMetadata), TransformError> {
1280 if is_png(bytes) {
1281 return Ok((MediaType::Png, sniff_png(bytes)?));
1282 }
1283
1284 if is_jpeg(bytes) {
1285 return Ok((MediaType::Jpeg, sniff_jpeg(bytes)?));
1286 }
1287
1288 if is_webp(bytes) {
1289 return Ok((MediaType::Webp, sniff_webp(bytes)?));
1290 }
1291
1292 if is_avif(bytes) {
1293 return Ok((MediaType::Avif, sniff_avif(bytes)?));
1294 }
1295
1296 if is_bmp(bytes) {
1297 return Ok((MediaType::Bmp, sniff_bmp(bytes)?));
1298 }
1299
1300 if is_tiff(bytes) {
1301 return Ok((MediaType::Tiff, sniff_tiff(bytes)?));
1302 }
1303
1304 if is_svg(bytes) {
1307 return Ok((MediaType::Svg, sniff_svg(bytes)));
1308 }
1309
1310 let preview_len = bytes.len().min(16);
1311 let hex_preview: String = bytes[..preview_len]
1312 .iter()
1313 .map(|b| format!("{b:02x}"))
1314 .collect::<Vec<_>>()
1315 .join(" ");
1316 Err(TransformError::UnsupportedInputMediaType(format!(
1317 "unknown file signature ({} bytes, header: [{hex_preview}])",
1318 bytes.len()
1319 )))
1320}
1321
1322fn is_png(bytes: &[u8]) -> bool {
1323 bytes.starts_with(b"\x89PNG\r\n\x1a\n")
1324}
1325
1326fn is_jpeg(bytes: &[u8]) -> bool {
1327 bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
1328}
1329
1330fn is_webp(bytes: &[u8]) -> bool {
1331 bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP"
1332}
1333
1334fn is_avif(bytes: &[u8]) -> bool {
1335 bytes.len() >= 16 && &bytes[4..8] == b"ftyp" && has_avif_brand(&bytes[8..])
1336}
1337
1338fn is_svg(bytes: &[u8]) -> bool {
1341 let text = match std::str::from_utf8(bytes) {
1342 Ok(s) => s,
1343 Err(_) => return false,
1344 };
1345
1346 let mut remaining = text.trim_start();
1347
1348 remaining = remaining.strip_prefix('\u{FEFF}').unwrap_or(remaining);
1350 remaining = remaining.trim_start();
1351
1352 if let Some(rest) = remaining.strip_prefix("<?xml") {
1354 if let Some(end) = rest.find("?>") {
1355 remaining = rest[end + 2..].trim_start();
1356 } else {
1357 return false;
1358 }
1359 }
1360
1361 if let Some(rest) = remaining.strip_prefix("<!DOCTYPE") {
1363 if let Some(end) = rest.find('>') {
1364 remaining = rest[end + 1..].trim_start();
1365 } else {
1366 return false;
1367 }
1368 }
1369
1370 while let Some(rest) = remaining.strip_prefix("<!--") {
1372 if let Some(end) = rest.find("-->") {
1373 remaining = rest[end + 3..].trim_start();
1374 } else {
1375 return false;
1376 }
1377 }
1378
1379 remaining.starts_with("<svg")
1380 && remaining
1381 .as_bytes()
1382 .get(4)
1383 .is_some_and(|&b| b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'>')
1384}
1385
1386fn sniff_svg(_bytes: &[u8]) -> ArtifactMetadata {
1390 ArtifactMetadata {
1391 width: None,
1392 height: None,
1393 frame_count: 1,
1394 duration: None,
1395 has_alpha: Some(true),
1396 }
1397}
1398
1399fn is_bmp(bytes: &[u8]) -> bool {
1401 bytes.len() >= 26 && bytes[0] == 0x42 && bytes[1] == 0x4D
1402}
1403
1404fn sniff_bmp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1411 if bytes.len() < 30 {
1412 return Err(TransformError::DecodeFailed(
1413 "bmp file is too short".to_string(),
1414 ));
1415 }
1416
1417 let width = u32::from_le_bytes([bytes[18], bytes[19], bytes[20], bytes[21]]);
1418 let raw_height = i32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]);
1419 let height = raw_height.unsigned_abs();
1420 let bits_per_pixel = u16::from_le_bytes([bytes[28], bytes[29]]);
1421
1422 let has_alpha = bits_per_pixel == 32;
1423
1424 Ok(ArtifactMetadata {
1425 width: Some(width),
1426 height: Some(height),
1427 frame_count: 1,
1428 duration: None,
1429 has_alpha: Some(has_alpha),
1430 })
1431}
1432
1433fn is_tiff(bytes: &[u8]) -> bool {
1437 bytes.len() >= 4
1438 && ((bytes[0] == b'I' && bytes[1] == b'I' && bytes[2] == 0x2A && bytes[3] == 0x00)
1439 || (bytes[0] == b'M' && bytes[1] == b'M' && bytes[2] == 0x00 && bytes[3] == 0x2A))
1440}
1441
1442fn sniff_tiff(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1444 let cursor = std::io::Cursor::new(bytes);
1445 let decoder = image::codecs::tiff::TiffDecoder::new(cursor)
1446 .map_err(|e| TransformError::DecodeFailed(format!("tiff decode: {e}")))?;
1447 let (width, height) = image::ImageDecoder::dimensions(&decoder);
1448 let color = image::ImageDecoder::color_type(&decoder);
1449 let has_alpha = matches!(
1450 color,
1451 image::ColorType::La8
1452 | image::ColorType::Rgba8
1453 | image::ColorType::La16
1454 | image::ColorType::Rgba16
1455 | image::ColorType::Rgba32F
1456 );
1457 Ok(ArtifactMetadata {
1458 width: Some(width),
1459 height: Some(height),
1460 frame_count: 1,
1461 duration: None,
1462 has_alpha: Some(has_alpha),
1463 })
1464}
1465
1466fn sniff_png(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1467 if bytes.len() < 29 {
1468 return Err(TransformError::DecodeFailed(
1469 "png file is too short".to_string(),
1470 ));
1471 }
1472
1473 if &bytes[12..16] != b"IHDR" {
1474 return Err(TransformError::DecodeFailed(
1475 "png file is missing an IHDR chunk".to_string(),
1476 ));
1477 }
1478
1479 let width = read_u32_be(&bytes[16..20])?;
1480 let height = read_u32_be(&bytes[20..24])?;
1481 let color_type = bytes[25];
1482 let has_alpha = match color_type {
1483 4 | 6 => Some(true),
1484 0 | 2 | 3 => Some(false),
1485 _ => None,
1486 };
1487
1488 Ok(ArtifactMetadata {
1489 width: Some(width),
1490 height: Some(height),
1491 frame_count: 1,
1492 duration: None,
1493 has_alpha,
1494 })
1495}
1496
1497fn sniff_jpeg(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1498 let mut offset = 2;
1499
1500 while offset + 1 < bytes.len() {
1501 if bytes[offset] != 0xFF {
1502 return Err(TransformError::DecodeFailed(
1503 "jpeg file has an invalid marker prefix".to_string(),
1504 ));
1505 }
1506
1507 while offset < bytes.len() && bytes[offset] == 0xFF {
1508 offset += 1;
1509 }
1510
1511 if offset >= bytes.len() {
1512 break;
1513 }
1514
1515 let marker = bytes[offset];
1516 offset += 1;
1517
1518 if marker == 0xD9 || marker == 0xDA {
1519 break;
1520 }
1521
1522 if (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
1523 continue;
1524 }
1525
1526 if offset + 2 > bytes.len() {
1527 return Err(TransformError::DecodeFailed(
1528 "jpeg segment is truncated".to_string(),
1529 ));
1530 }
1531
1532 let segment_length = read_u16_be(&bytes[offset..offset + 2])? as usize;
1533 if segment_length < 2 || offset + segment_length > bytes.len() {
1534 return Err(TransformError::DecodeFailed(
1535 "jpeg segment length is invalid".to_string(),
1536 ));
1537 }
1538
1539 if is_jpeg_sof_marker(marker) {
1540 if segment_length < 7 {
1541 return Err(TransformError::DecodeFailed(
1542 "jpeg SOF segment is too short".to_string(),
1543 ));
1544 }
1545
1546 let height = read_u16_be(&bytes[offset + 3..offset + 5])? as u32;
1547 let width = read_u16_be(&bytes[offset + 5..offset + 7])? as u32;
1548
1549 return Ok(ArtifactMetadata {
1550 width: Some(width),
1551 height: Some(height),
1552 frame_count: 1,
1553 duration: None,
1554 has_alpha: Some(false),
1555 });
1556 }
1557
1558 offset += segment_length;
1559 }
1560
1561 Err(TransformError::DecodeFailed(
1562 "jpeg file is missing a SOF segment".to_string(),
1563 ))
1564}
1565
1566fn sniff_webp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1567 let mut offset = 12;
1568
1569 while offset + 8 <= bytes.len() {
1570 let chunk_tag = &bytes[offset..offset + 4];
1571 let chunk_size = read_u32_le(&bytes[offset + 4..offset + 8])? as usize;
1572 let chunk_start = offset + 8;
1573 let chunk_end = chunk_start
1574 .checked_add(chunk_size)
1575 .ok_or_else(|| TransformError::DecodeFailed("webp chunk is too large".to_string()))?;
1576
1577 if chunk_end > bytes.len() {
1578 return Err(TransformError::DecodeFailed(
1579 "webp chunk exceeds file length".to_string(),
1580 ));
1581 }
1582
1583 let chunk_data = &bytes[chunk_start..chunk_end];
1584
1585 match chunk_tag {
1586 b"VP8X" => return sniff_webp_vp8x(chunk_data),
1587 b"VP8 " => return sniff_webp_vp8(chunk_data),
1588 b"VP8L" => return sniff_webp_vp8l(chunk_data),
1589 _ => {}
1590 }
1591
1592 offset = chunk_end + (chunk_size % 2);
1593 }
1594
1595 Err(TransformError::DecodeFailed(
1596 "webp file is missing an image chunk".to_string(),
1597 ))
1598}
1599
1600fn sniff_webp_vp8x(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1601 if bytes.len() < 10 {
1602 return Err(TransformError::DecodeFailed(
1603 "webp VP8X chunk is too short".to_string(),
1604 ));
1605 }
1606
1607 let flags = bytes[0];
1608 let width = read_u24_le(&bytes[4..7])? + 1;
1609 let height = read_u24_le(&bytes[7..10])? + 1;
1610 let has_alpha = Some(flags & 0b0001_0000 != 0);
1611
1612 Ok(ArtifactMetadata {
1613 width: Some(width),
1614 height: Some(height),
1615 frame_count: 1,
1616 duration: None,
1617 has_alpha,
1618 })
1619}
1620
1621fn sniff_webp_vp8(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1622 if bytes.len() < 10 {
1623 return Err(TransformError::DecodeFailed(
1624 "webp VP8 chunk is too short".to_string(),
1625 ));
1626 }
1627
1628 if bytes[3..6] != [0x9D, 0x01, 0x2A] {
1629 return Err(TransformError::DecodeFailed(
1630 "webp VP8 chunk has an invalid start code".to_string(),
1631 ));
1632 }
1633
1634 let width = (read_u16_le(&bytes[6..8])? & 0x3FFF) as u32;
1635 let height = (read_u16_le(&bytes[8..10])? & 0x3FFF) as u32;
1636
1637 Ok(ArtifactMetadata {
1638 width: Some(width),
1639 height: Some(height),
1640 frame_count: 1,
1641 duration: None,
1642 has_alpha: Some(false),
1643 })
1644}
1645
1646fn sniff_webp_vp8l(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1647 if bytes.len() < 5 {
1648 return Err(TransformError::DecodeFailed(
1649 "webp VP8L chunk is too short".to_string(),
1650 ));
1651 }
1652
1653 if bytes[0] != 0x2F {
1654 return Err(TransformError::DecodeFailed(
1655 "webp VP8L chunk has an invalid signature".to_string(),
1656 ));
1657 }
1658
1659 let bits = read_u32_le(&bytes[1..5])?;
1660 let width = (bits & 0x3FFF) + 1;
1661 let height = ((bits >> 14) & 0x3FFF) + 1;
1662
1663 Ok(ArtifactMetadata {
1664 width: Some(width),
1665 height: Some(height),
1666 frame_count: 1,
1667 duration: None,
1668 has_alpha: None,
1669 })
1670}
1671
1672fn sniff_avif(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1673 if bytes.len() < 16 {
1674 return Err(TransformError::DecodeFailed(
1675 "avif file is too short".to_string(),
1676 ));
1677 }
1678
1679 if !has_avif_brand(&bytes[8..]) {
1680 return Err(TransformError::DecodeFailed(
1681 "avif file is missing a compatible AVIF brand".to_string(),
1682 ));
1683 }
1684
1685 let inspection = inspect_avif_container(bytes)?;
1686
1687 Ok(ArtifactMetadata {
1688 width: inspection.dimensions.map(|(width, _)| width),
1689 height: inspection.dimensions.map(|(_, height)| height),
1690 frame_count: 1,
1691 duration: None,
1692 has_alpha: inspection.has_alpha(),
1693 })
1694}
1695
1696fn has_avif_brand(bytes: &[u8]) -> bool {
1697 if bytes.len() < 8 {
1698 return false;
1699 }
1700
1701 if is_avif_brand(&bytes[0..4]) {
1702 return true;
1703 }
1704
1705 let mut offset = 8;
1706 while offset + 4 <= bytes.len() {
1707 if is_avif_brand(&bytes[offset..offset + 4]) {
1708 return true;
1709 }
1710 offset += 4;
1711 }
1712
1713 false
1714}
1715
1716fn is_avif_brand(bytes: &[u8]) -> bool {
1717 matches!(bytes, b"avif" | b"avis")
1718}
1719
1720const AVIF_ALPHA_AUX_TYPE: &[u8] = b"urn:mpeg:mpegB:cicp:systems:auxiliary:alpha";
1721
1722#[derive(Debug, Default)]
1723struct AvifInspection {
1724 dimensions: Option<(u32, u32)>,
1725 saw_structured_meta: bool,
1726 found_alpha_item: bool,
1727}
1728
1729impl AvifInspection {
1730 fn has_alpha(&self) -> Option<bool> {
1731 if self.saw_structured_meta {
1732 Some(self.found_alpha_item)
1733 } else {
1734 None
1735 }
1736 }
1737}
1738
1739fn inspect_avif_container(bytes: &[u8]) -> Result<AvifInspection, TransformError> {
1740 let mut inspection = AvifInspection::default();
1741 inspect_avif_boxes(bytes, &mut inspection)?;
1742 Ok(inspection)
1743}
1744
1745fn inspect_avif_boxes(bytes: &[u8], inspection: &mut AvifInspection) -> Result<(), TransformError> {
1746 let mut offset = 0;
1747
1748 while offset + 8 <= bytes.len() {
1749 let (box_type, payload, next_offset) = parse_mp4_box(bytes, offset)?;
1750
1751 match box_type {
1752 b"meta" | b"iref" => {
1753 inspection.saw_structured_meta = true;
1754 if payload.len() < 4 {
1755 return Err(TransformError::DecodeFailed(format!(
1756 "{} box is too short",
1757 String::from_utf8_lossy(box_type)
1758 )));
1759 }
1760 inspect_avif_boxes(&payload[4..], inspection)?;
1761 }
1762 b"iprp" | b"ipco" => {
1763 inspection.saw_structured_meta = true;
1764 inspect_avif_boxes(payload, inspection)?;
1765 }
1766 b"ispe" => {
1767 inspection.saw_structured_meta = true;
1768 if inspection.dimensions.is_none() {
1769 inspection.dimensions = Some(parse_avif_ispe(payload)?);
1770 }
1771 }
1772 b"auxC" => {
1773 inspection.saw_structured_meta = true;
1774 if avif_auxc_declares_alpha(payload)? {
1775 inspection.found_alpha_item = true;
1776 }
1777 }
1778 b"auxl" => {
1779 inspection.saw_structured_meta = true;
1780 inspection.found_alpha_item = true;
1781 }
1782 _ => {}
1783 }
1784
1785 offset = next_offset;
1786 }
1787
1788 if offset != bytes.len() {
1789 return Err(TransformError::DecodeFailed(
1790 "avif box payload has trailing bytes".to_string(),
1791 ));
1792 }
1793
1794 Ok(())
1795}
1796
1797fn parse_mp4_box(bytes: &[u8], offset: usize) -> Result<(&[u8; 4], &[u8], usize), TransformError> {
1798 if offset + 8 > bytes.len() {
1799 return Err(TransformError::DecodeFailed(
1800 "mp4 box header is truncated".to_string(),
1801 ));
1802 }
1803
1804 let size = read_u32_be(&bytes[offset..offset + 4])?;
1805 let box_type = bytes[offset + 4..offset + 8]
1806 .try_into()
1807 .map_err(|_| TransformError::DecodeFailed("expected 4-byte box type".to_string()))?;
1808 let mut header_len = 8_usize;
1809 let end = match size {
1810 0 => bytes.len(),
1811 1 => {
1812 if offset + 16 > bytes.len() {
1813 return Err(TransformError::DecodeFailed(
1814 "extended mp4 box header is truncated".to_string(),
1815 ));
1816 }
1817 header_len = 16;
1818 let extended_size = read_u64_be(&bytes[offset + 8..offset + 16])?;
1819 usize::try_from(extended_size)
1820 .map_err(|_| TransformError::DecodeFailed("mp4 box is too large".to_string()))?
1821 }
1822 _ => size as usize,
1823 };
1824
1825 if end < header_len {
1826 return Err(TransformError::DecodeFailed(
1827 "mp4 box size is smaller than its header".to_string(),
1828 ));
1829 }
1830
1831 let box_end = offset
1832 .checked_add(end)
1833 .ok_or_else(|| TransformError::DecodeFailed("mp4 box is too large".to_string()))?;
1834 if box_end > bytes.len() {
1835 return Err(TransformError::DecodeFailed(
1836 "mp4 box exceeds file length".to_string(),
1837 ));
1838 }
1839
1840 Ok((box_type, &bytes[offset + header_len..box_end], box_end))
1841}
1842
1843fn parse_avif_ispe(bytes: &[u8]) -> Result<(u32, u32), TransformError> {
1844 if bytes.len() < 12 {
1845 return Err(TransformError::DecodeFailed(
1846 "avif ispe box is too short".to_string(),
1847 ));
1848 }
1849
1850 let width = read_u32_be(&bytes[4..8])?;
1851 let height = read_u32_be(&bytes[8..12])?;
1852 Ok((width, height))
1853}
1854
1855fn avif_auxc_declares_alpha(bytes: &[u8]) -> Result<bool, TransformError> {
1856 if bytes.len() < 5 {
1857 return Err(TransformError::DecodeFailed(
1858 "avif auxC box is too short".to_string(),
1859 ));
1860 }
1861
1862 let urn = &bytes[4..];
1863 Ok(urn
1864 .strip_suffix(&[0])
1865 .is_some_and(|urn| urn == AVIF_ALPHA_AUX_TYPE))
1866}
1867
1868fn is_jpeg_sof_marker(marker: u8) -> bool {
1869 matches!(
1870 marker,
1871 0xC0 | 0xC1 | 0xC2 | 0xC3 | 0xC5 | 0xC6 | 0xC7 | 0xC9 | 0xCA | 0xCB | 0xCD | 0xCE | 0xCF
1872 )
1873}
1874
1875fn read_u16_be(bytes: &[u8]) -> Result<u16, TransformError> {
1876 let array: [u8; 2] = bytes
1877 .try_into()
1878 .map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
1879 Ok(u16::from_be_bytes(array))
1880}
1881
1882fn read_u16_le(bytes: &[u8]) -> Result<u16, TransformError> {
1883 let array: [u8; 2] = bytes
1884 .try_into()
1885 .map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
1886 Ok(u16::from_le_bytes(array))
1887}
1888
1889fn read_u24_le(bytes: &[u8]) -> Result<u32, TransformError> {
1890 if bytes.len() != 3 {
1891 return Err(TransformError::DecodeFailed("expected 3 bytes".to_string()));
1892 }
1893
1894 Ok(u32::from(bytes[0]) | (u32::from(bytes[1]) << 8) | (u32::from(bytes[2]) << 16))
1895}
1896
1897fn read_u32_be(bytes: &[u8]) -> Result<u32, TransformError> {
1898 let array: [u8; 4] = bytes
1899 .try_into()
1900 .map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
1901 Ok(u32::from_be_bytes(array))
1902}
1903
1904fn read_u32_le(bytes: &[u8]) -> Result<u32, TransformError> {
1905 let array: [u8; 4] = bytes
1906 .try_into()
1907 .map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
1908 Ok(u32::from_le_bytes(array))
1909}
1910
1911fn read_u64_be(bytes: &[u8]) -> Result<u64, TransformError> {
1912 let array: [u8; 8] = bytes
1913 .try_into()
1914 .map_err(|_| TransformError::DecodeFailed("expected 8 bytes".to_string()))?;
1915 Ok(u64::from_be_bytes(array))
1916}
1917
1918#[cfg(test)]
1919mod tests {
1920 use super::{
1921 Artifact, ArtifactMetadata, Fit, MediaType, MetadataPolicy, Position, RawArtifact, Rgba8,
1922 Rotation, TransformError, TransformOptions, TransformRequest, sniff_artifact,
1923 };
1924 use image::codecs::avif::AvifEncoder;
1925 use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
1926
1927 fn jpeg_artifact() -> Artifact {
1928 Artifact::new(vec![1, 2, 3], MediaType::Jpeg, ArtifactMetadata::default())
1929 }
1930
1931 fn png_bytes(width: u32, height: u32, color_type: u8) -> Vec<u8> {
1932 let mut bytes = Vec::new();
1933 bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
1934 bytes.extend_from_slice(&13_u32.to_be_bytes());
1935 bytes.extend_from_slice(b"IHDR");
1936 bytes.extend_from_slice(&width.to_be_bytes());
1937 bytes.extend_from_slice(&height.to_be_bytes());
1938 bytes.push(8);
1939 bytes.push(color_type);
1940 bytes.push(0);
1941 bytes.push(0);
1942 bytes.push(0);
1943 bytes.extend_from_slice(&0_u32.to_be_bytes());
1944 bytes
1945 }
1946
1947 fn jpeg_bytes(width: u16, height: u16) -> Vec<u8> {
1948 let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
1949 bytes.extend_from_slice(&[0; 14]);
1950 bytes.extend_from_slice(&[
1951 0xFF,
1952 0xC0,
1953 0x00,
1954 0x11,
1955 0x08,
1956 (height >> 8) as u8,
1957 height as u8,
1958 (width >> 8) as u8,
1959 width as u8,
1960 0x03,
1961 0x01,
1962 0x11,
1963 0x00,
1964 0x02,
1965 0x11,
1966 0x00,
1967 0x03,
1968 0x11,
1969 0x00,
1970 ]);
1971 bytes.extend_from_slice(&[0xFF, 0xD9]);
1972 bytes
1973 }
1974
1975 fn webp_vp8x_bytes(width: u32, height: u32, flags: u8) -> Vec<u8> {
1976 let width_minus_one = width - 1;
1977 let height_minus_one = height - 1;
1978 let mut bytes = Vec::new();
1979 bytes.extend_from_slice(b"RIFF");
1980 bytes.extend_from_slice(&30_u32.to_le_bytes());
1981 bytes.extend_from_slice(b"WEBP");
1982 bytes.extend_from_slice(b"VP8X");
1983 bytes.extend_from_slice(&10_u32.to_le_bytes());
1984 bytes.push(flags);
1985 bytes.extend_from_slice(&[0, 0, 0]);
1986 bytes.extend_from_slice(&[
1987 (width_minus_one & 0xFF) as u8,
1988 ((width_minus_one >> 8) & 0xFF) as u8,
1989 ((width_minus_one >> 16) & 0xFF) as u8,
1990 ]);
1991 bytes.extend_from_slice(&[
1992 (height_minus_one & 0xFF) as u8,
1993 ((height_minus_one >> 8) & 0xFF) as u8,
1994 ((height_minus_one >> 16) & 0xFF) as u8,
1995 ]);
1996 bytes
1997 }
1998
1999 fn webp_vp8l_bytes(width: u32, height: u32) -> Vec<u8> {
2000 let packed = (width - 1) | ((height - 1) << 14);
2001 let mut bytes = Vec::new();
2002 bytes.extend_from_slice(b"RIFF");
2003 bytes.extend_from_slice(&17_u32.to_le_bytes());
2004 bytes.extend_from_slice(b"WEBP");
2005 bytes.extend_from_slice(b"VP8L");
2006 bytes.extend_from_slice(&5_u32.to_le_bytes());
2007 bytes.push(0x2F);
2008 bytes.extend_from_slice(&packed.to_le_bytes());
2009 bytes.push(0);
2010 bytes
2011 }
2012
2013 fn avif_bytes() -> Vec<u8> {
2014 let mut bytes = Vec::new();
2015 bytes.extend_from_slice(&24_u32.to_be_bytes());
2016 bytes.extend_from_slice(b"ftyp");
2017 bytes.extend_from_slice(b"avif");
2018 bytes.extend_from_slice(&0_u32.to_be_bytes());
2019 bytes.extend_from_slice(b"mif1");
2020 bytes.extend_from_slice(b"avif");
2021 bytes
2022 }
2023
2024 fn encoded_avif_bytes(width: u32, height: u32, fill: Rgba<u8>) -> Vec<u8> {
2025 let image = RgbaImage::from_pixel(width, height, fill);
2026 let mut bytes = Vec::new();
2027 AvifEncoder::new(&mut bytes)
2028 .write_image(&image, width, height, ColorType::Rgba8.into())
2029 .expect("encode avif");
2030 bytes
2031 }
2032
2033 #[test]
2034 fn default_transform_options_match_documented_defaults() {
2035 let options = TransformOptions::default();
2036
2037 assert_eq!(options.width, None);
2038 assert_eq!(options.height, None);
2039 assert_eq!(options.fit, None);
2040 assert_eq!(options.position, None);
2041 assert_eq!(options.format, None);
2042 assert_eq!(options.quality, None);
2043 assert_eq!(options.rotate, Rotation::Deg0);
2044 assert!(options.auto_orient);
2045 assert!(options.strip_metadata);
2046 assert!(!options.preserve_exif);
2047 }
2048
2049 #[test]
2050 fn media_type_helpers_report_expected_values() {
2051 assert_eq!(MediaType::Jpeg.as_name(), "jpeg");
2052 assert_eq!(MediaType::Jpeg.as_mime(), "image/jpeg");
2053 assert!(MediaType::Webp.is_lossy());
2054 assert!(!MediaType::Png.is_lossy());
2055 }
2056
2057 #[test]
2058 fn media_type_parsing_accepts_documented_names() {
2059 assert_eq!("jpeg".parse::<MediaType>(), Ok(MediaType::Jpeg));
2060 assert_eq!("jpg".parse::<MediaType>(), Ok(MediaType::Jpeg));
2061 assert_eq!("png".parse::<MediaType>(), Ok(MediaType::Png));
2062 assert!("gif".parse::<MediaType>().is_err());
2063 }
2064
2065 #[test]
2066 fn fit_position_rotation_and_color_parsing_work() {
2067 assert_eq!("cover".parse::<Fit>(), Ok(Fit::Cover));
2068 assert_eq!(
2069 "bottom-right".parse::<Position>(),
2070 Ok(Position::BottomRight)
2071 );
2072 assert_eq!("270".parse::<Rotation>(), Ok(Rotation::Deg270));
2073 assert_eq!(
2074 Rgba8::from_hex("AABBCCDD"),
2075 Ok(Rgba8 {
2076 r: 0xAA,
2077 g: 0xBB,
2078 b: 0xCC,
2079 a: 0xDD
2080 })
2081 );
2082 assert!(Rgba8::from_hex("AABB").is_err());
2083 }
2084
2085 #[test]
2086 fn normalize_defaults_fit_and_position_for_bounded_resize() {
2087 let normalized = TransformOptions {
2088 width: Some(1200),
2089 height: Some(630),
2090 ..TransformOptions::default()
2091 }
2092 .normalize(MediaType::Jpeg)
2093 .expect("normalize bounded resize");
2094
2095 assert_eq!(normalized.fit, Some(Fit::Contain));
2096 assert_eq!(normalized.position, Position::Center);
2097 assert_eq!(normalized.format, MediaType::Jpeg);
2098 assert_eq!(normalized.metadata_policy, MetadataPolicy::StripAll);
2099 }
2100
2101 #[test]
2102 fn normalize_uses_requested_fit_and_output_format() {
2103 let normalized = TransformOptions {
2104 width: Some(320),
2105 height: Some(320),
2106 fit: Some(Fit::Cover),
2107 position: Some(Position::BottomRight),
2108 format: Some(MediaType::Webp),
2109 quality: Some(70),
2110 strip_metadata: false,
2111 preserve_exif: true,
2112 ..TransformOptions::default()
2113 }
2114 .normalize(MediaType::Jpeg)
2115 .expect("normalize explicit values");
2116
2117 assert_eq!(normalized.fit, Some(Fit::Cover));
2118 assert_eq!(normalized.position, Position::BottomRight);
2119 assert_eq!(normalized.format, MediaType::Webp);
2120 assert_eq!(normalized.quality, Some(70));
2121 assert_eq!(normalized.metadata_policy, MetadataPolicy::PreserveExif);
2122 }
2123
2124 #[test]
2125 fn normalize_can_keep_all_metadata() {
2126 let normalized = TransformOptions {
2127 strip_metadata: false,
2128 ..TransformOptions::default()
2129 }
2130 .normalize(MediaType::Jpeg)
2131 .expect("normalize keep metadata");
2132
2133 assert_eq!(normalized.metadata_policy, MetadataPolicy::KeepAll);
2134 }
2135
2136 #[test]
2137 fn normalize_keeps_fit_none_when_resize_is_not_bounded() {
2138 let normalized = TransformOptions {
2139 width: Some(500),
2140 ..TransformOptions::default()
2141 }
2142 .normalize(MediaType::Jpeg)
2143 .expect("normalize unbounded resize");
2144
2145 assert_eq!(normalized.fit, None);
2146 assert_eq!(normalized.position, Position::Center);
2147 }
2148
2149 #[test]
2150 fn normalize_rejects_zero_dimensions() {
2151 let err = TransformOptions {
2152 width: Some(0),
2153 ..TransformOptions::default()
2154 }
2155 .normalize(MediaType::Jpeg)
2156 .expect_err("zero width should fail");
2157
2158 assert_eq!(
2159 err,
2160 TransformError::InvalidOptions("width must be greater than zero".to_string())
2161 );
2162 }
2163
2164 #[test]
2165 fn normalize_rejects_fit_without_both_dimensions() {
2166 let err = TransformOptions {
2167 width: Some(300),
2168 fit: Some(Fit::Contain),
2169 ..TransformOptions::default()
2170 }
2171 .normalize(MediaType::Jpeg)
2172 .expect_err("fit without bounded resize should fail");
2173
2174 assert_eq!(
2175 err,
2176 TransformError::InvalidOptions("fit requires both width and height".to_string())
2177 );
2178 }
2179
2180 #[test]
2181 fn normalize_rejects_position_without_both_dimensions() {
2182 let err = TransformOptions {
2183 height: Some(300),
2184 position: Some(Position::Top),
2185 ..TransformOptions::default()
2186 }
2187 .normalize(MediaType::Jpeg)
2188 .expect_err("position without bounded resize should fail");
2189
2190 assert_eq!(
2191 err,
2192 TransformError::InvalidOptions("position requires both width and height".to_string())
2193 );
2194 }
2195
2196 #[test]
2197 fn normalize_rejects_quality_for_lossless_output() {
2198 let err = TransformOptions {
2199 format: Some(MediaType::Png),
2200 quality: Some(80),
2201 ..TransformOptions::default()
2202 }
2203 .normalize(MediaType::Jpeg)
2204 .expect_err("quality for png should fail");
2205
2206 assert_eq!(
2207 err,
2208 TransformError::InvalidOptions("quality requires a lossy output format".to_string())
2209 );
2210 }
2211
2212 #[test]
2213 fn normalize_rejects_zero_quality() {
2214 let err = TransformOptions {
2215 quality: Some(0),
2216 ..TransformOptions::default()
2217 }
2218 .normalize(MediaType::Jpeg)
2219 .expect_err("zero quality should fail");
2220
2221 assert_eq!(
2222 err,
2223 TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
2224 );
2225 }
2226
2227 #[test]
2228 fn normalize_rejects_quality_above_one_hundred() {
2229 let err = TransformOptions {
2230 quality: Some(101),
2231 ..TransformOptions::default()
2232 }
2233 .normalize(MediaType::Jpeg)
2234 .expect_err("quality above one hundred should fail");
2235
2236 assert_eq!(
2237 err,
2238 TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
2239 );
2240 }
2241
2242 #[test]
2243 fn normalize_rejects_preserve_exif_when_metadata_is_stripped() {
2244 let err = TransformOptions {
2245 preserve_exif: true,
2246 ..TransformOptions::default()
2247 }
2248 .normalize(MediaType::Jpeg)
2249 .expect_err("preserve_exif should require metadata retention");
2250
2251 assert_eq!(
2252 err,
2253 TransformError::InvalidOptions(
2254 "preserve_exif requires strip_metadata to be false".to_string()
2255 )
2256 );
2257 }
2258
2259 #[test]
2260 fn transform_request_normalize_uses_input_media_type_as_default_output() {
2261 let request = TransformRequest::new(jpeg_artifact(), TransformOptions::default());
2262 let normalized = request.normalize().expect("normalize request");
2263
2264 assert_eq!(normalized.input.media_type, MediaType::Jpeg);
2265 assert_eq!(normalized.options.format, MediaType::Jpeg);
2266 assert_eq!(normalized.options.metadata_policy, MetadataPolicy::StripAll);
2267 }
2268
2269 #[test]
2270 fn sniff_artifact_detects_png_dimensions_and_alpha() {
2271 let artifact =
2272 sniff_artifact(RawArtifact::new(png_bytes(64, 32, 6), None)).expect("sniff png");
2273
2274 assert_eq!(artifact.media_type, MediaType::Png);
2275 assert_eq!(artifact.metadata.width, Some(64));
2276 assert_eq!(artifact.metadata.height, Some(32));
2277 assert_eq!(artifact.metadata.has_alpha, Some(true));
2278 }
2279
2280 #[test]
2281 fn sniff_artifact_detects_jpeg_dimensions() {
2282 let artifact =
2283 sniff_artifact(RawArtifact::new(jpeg_bytes(320, 240), None)).expect("sniff jpeg");
2284
2285 assert_eq!(artifact.media_type, MediaType::Jpeg);
2286 assert_eq!(artifact.metadata.width, Some(320));
2287 assert_eq!(artifact.metadata.height, Some(240));
2288 assert_eq!(artifact.metadata.has_alpha, Some(false));
2289 }
2290
2291 #[test]
2292 fn sniff_artifact_detects_webp_vp8x_dimensions() {
2293 let artifact = sniff_artifact(RawArtifact::new(
2294 webp_vp8x_bytes(800, 600, 0b0001_0000),
2295 None,
2296 ))
2297 .expect("sniff webp vp8x");
2298
2299 assert_eq!(artifact.media_type, MediaType::Webp);
2300 assert_eq!(artifact.metadata.width, Some(800));
2301 assert_eq!(artifact.metadata.height, Some(600));
2302 assert_eq!(artifact.metadata.has_alpha, Some(true));
2303 }
2304
2305 #[test]
2306 fn sniff_artifact_detects_webp_vp8l_dimensions() {
2307 let artifact = sniff_artifact(RawArtifact::new(webp_vp8l_bytes(123, 77), None))
2308 .expect("sniff webp vp8l");
2309
2310 assert_eq!(artifact.media_type, MediaType::Webp);
2311 assert_eq!(artifact.metadata.width, Some(123));
2312 assert_eq!(artifact.metadata.height, Some(77));
2313 }
2314
2315 #[test]
2316 fn sniff_artifact_detects_avif_brand() {
2317 let artifact = sniff_artifact(RawArtifact::new(avif_bytes(), None)).expect("sniff avif");
2318
2319 assert_eq!(artifact.media_type, MediaType::Avif);
2320 assert_eq!(artifact.metadata, ArtifactMetadata::default());
2321 }
2322
2323 #[test]
2324 fn sniff_artifact_detects_avif_dimensions_and_alpha() {
2325 let artifact = sniff_artifact(RawArtifact::new(
2326 encoded_avif_bytes(7, 5, Rgba([10, 20, 30, 0])),
2327 None,
2328 ))
2329 .expect("sniff avif with alpha");
2330
2331 assert_eq!(artifact.media_type, MediaType::Avif);
2332 assert_eq!(artifact.metadata.width, Some(7));
2333 assert_eq!(artifact.metadata.height, Some(5));
2334 assert_eq!(artifact.metadata.has_alpha, Some(true));
2335 }
2336
2337 #[test]
2338 fn sniff_artifact_detects_opaque_avif_without_alpha_item() {
2339 let artifact = sniff_artifact(RawArtifact::new(
2340 encoded_avif_bytes(9, 4, Rgba([10, 20, 30, 255])),
2341 None,
2342 ))
2343 .expect("sniff opaque avif");
2344
2345 assert_eq!(artifact.media_type, MediaType::Avif);
2346 assert_eq!(artifact.metadata.width, Some(9));
2347 assert_eq!(artifact.metadata.height, Some(4));
2348 assert_eq!(artifact.metadata.has_alpha, Some(false));
2349 }
2350
2351 #[test]
2352 fn sniff_artifact_rejects_declared_media_type_mismatch() {
2353 let err = sniff_artifact(RawArtifact::new(png_bytes(8, 8, 2), Some(MediaType::Jpeg)))
2354 .expect_err("declared mismatch should fail");
2355
2356 assert_eq!(
2357 err,
2358 TransformError::InvalidInput(
2359 "declared media type does not match detected media type".to_string()
2360 )
2361 );
2362 }
2363
2364 #[test]
2365 fn sniff_artifact_rejects_unknown_signatures() {
2366 let err =
2367 sniff_artifact(RawArtifact::new(vec![1, 2, 3, 4], None)).expect_err("unknown bytes");
2368
2369 assert!(
2370 matches!(err, TransformError::UnsupportedInputMediaType(ref msg) if msg.contains("unknown file signature")),
2371 "expected unknown file signature error, got: {err}"
2372 );
2373 let msg = err.to_string();
2374 assert!(msg.contains("4 bytes"), "should include file size: {msg}");
2375 assert!(
2376 msg.contains("01 02 03 04"),
2377 "should include hex preview: {msg}"
2378 );
2379 }
2380
2381 #[test]
2382 fn sniff_artifact_rejects_invalid_png_structure() {
2383 let err = sniff_artifact(RawArtifact::new(b"\x89PNG\r\n\x1a\nbroken".to_vec(), None))
2384 .expect_err("broken png should fail");
2385
2386 assert_eq!(
2387 err,
2388 TransformError::DecodeFailed("png file is too short".to_string())
2389 );
2390 }
2391
2392 #[test]
2393 fn sniff_artifact_detects_bmp_dimensions() {
2394 let mut bmp = Vec::new();
2397 bmp.extend_from_slice(b"BM");
2399 bmp.extend_from_slice(&0u32.to_le_bytes());
2401 bmp.extend_from_slice(&0u32.to_le_bytes());
2403 bmp.extend_from_slice(&54u32.to_le_bytes());
2405 bmp.extend_from_slice(&40u32.to_le_bytes());
2407 bmp.extend_from_slice(&8u32.to_le_bytes());
2409 bmp.extend_from_slice(&6i32.to_le_bytes());
2411 bmp.extend_from_slice(&1u16.to_le_bytes());
2413 bmp.extend_from_slice(&24u16.to_le_bytes());
2415 bmp.resize(54, 0);
2417
2418 let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
2419 assert_eq!(artifact.media_type, MediaType::Bmp);
2420 assert_eq!(artifact.metadata.width, Some(8));
2421 assert_eq!(artifact.metadata.height, Some(6));
2422 assert_eq!(artifact.metadata.has_alpha, Some(false));
2423 }
2424
2425 #[test]
2426 fn sniff_artifact_detects_bmp_32bit_alpha() {
2427 let mut bmp = Vec::new();
2428 bmp.extend_from_slice(b"BM");
2429 bmp.extend_from_slice(&0u32.to_le_bytes());
2430 bmp.extend_from_slice(&0u32.to_le_bytes());
2431 bmp.extend_from_slice(&54u32.to_le_bytes());
2432 bmp.extend_from_slice(&40u32.to_le_bytes());
2433 bmp.extend_from_slice(&4u32.to_le_bytes());
2435 bmp.extend_from_slice(&4i32.to_le_bytes());
2437 bmp.extend_from_slice(&1u16.to_le_bytes());
2439 bmp.extend_from_slice(&32u16.to_le_bytes());
2441 bmp.resize(54, 0);
2442
2443 let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
2444 assert_eq!(artifact.media_type, MediaType::Bmp);
2445 assert_eq!(artifact.metadata.has_alpha, Some(true));
2446 }
2447
2448 #[test]
2449 fn sniff_artifact_rejects_too_short_bmp() {
2450 let mut data = b"BM".to_vec();
2452 data.resize(27, 0);
2453 let err =
2454 sniff_artifact(RawArtifact::new(data, None)).expect_err("too-short BMP should fail");
2455
2456 assert_eq!(
2457 err,
2458 TransformError::DecodeFailed("bmp file is too short".to_string())
2459 );
2460 }
2461
2462 #[test]
2463 fn normalize_rejects_blur_sigma_below_minimum() {
2464 let err = TransformOptions {
2465 blur: Some(0.0),
2466 ..TransformOptions::default()
2467 }
2468 .normalize(MediaType::Jpeg)
2469 .expect_err("blur sigma 0.0 should be rejected");
2470
2471 assert_eq!(
2472 err,
2473 TransformError::InvalidOptions("blur sigma must be between 0.1 and 100.0".to_string())
2474 );
2475 }
2476
2477 #[test]
2478 fn normalize_rejects_blur_sigma_above_maximum() {
2479 let err = TransformOptions {
2480 blur: Some(100.1),
2481 ..TransformOptions::default()
2482 }
2483 .normalize(MediaType::Jpeg)
2484 .expect_err("blur sigma 100.1 should be rejected");
2485
2486 assert_eq!(
2487 err,
2488 TransformError::InvalidOptions("blur sigma must be between 0.1 and 100.0".to_string())
2489 );
2490 }
2491
2492 #[test]
2493 fn normalize_accepts_blur_sigma_at_boundaries() {
2494 let opts_min = TransformOptions {
2495 blur: Some(0.1),
2496 ..TransformOptions::default()
2497 }
2498 .normalize(MediaType::Jpeg)
2499 .expect("blur sigma 0.1 should be accepted");
2500 assert_eq!(opts_min.blur, Some(0.1));
2501
2502 let opts_max = TransformOptions {
2503 blur: Some(100.0),
2504 ..TransformOptions::default()
2505 }
2506 .normalize(MediaType::Jpeg)
2507 .expect("blur sigma 100.0 should be accepted");
2508 assert_eq!(opts_max.blur, Some(100.0));
2509 }
2510
2511 #[test]
2512 fn normalize_rejects_sharpen_sigma_below_minimum() {
2513 let err = TransformOptions {
2514 sharpen: Some(0.0),
2515 ..TransformOptions::default()
2516 }
2517 .normalize(MediaType::Jpeg)
2518 .expect_err("sharpen sigma 0.0 should be rejected");
2519
2520 assert_eq!(
2521 err,
2522 TransformError::InvalidOptions(
2523 "sharpen sigma must be between 0.1 and 100.0".to_string()
2524 )
2525 );
2526 }
2527
2528 #[test]
2529 fn normalize_rejects_sharpen_sigma_above_maximum() {
2530 let err = TransformOptions {
2531 sharpen: Some(100.1),
2532 ..TransformOptions::default()
2533 }
2534 .normalize(MediaType::Jpeg)
2535 .expect_err("sharpen sigma 100.1 should be rejected");
2536
2537 assert_eq!(
2538 err,
2539 TransformError::InvalidOptions(
2540 "sharpen sigma must be between 0.1 and 100.0".to_string()
2541 )
2542 );
2543 }
2544
2545 #[test]
2546 fn normalize_accepts_sharpen_sigma_at_boundaries() {
2547 let opts_min = TransformOptions {
2548 sharpen: Some(0.1),
2549 ..TransformOptions::default()
2550 }
2551 .normalize(MediaType::Jpeg)
2552 .expect("sharpen sigma 0.1 should be accepted");
2553 assert_eq!(opts_min.sharpen, Some(0.1));
2554
2555 let opts_max = TransformOptions {
2556 sharpen: Some(100.0),
2557 ..TransformOptions::default()
2558 }
2559 .normalize(MediaType::Jpeg)
2560 .expect("sharpen sigma 100.0 should be accepted");
2561 assert_eq!(opts_max.sharpen, Some(100.0));
2562 }
2563
2564 #[test]
2565 fn validate_watermark_rejects_zero_opacity() {
2566 let wm = super::WatermarkInput {
2567 image: jpeg_artifact(),
2568 position: Position::BottomRight,
2569 opacity: 0,
2570 margin: 10,
2571 };
2572 let err = super::validate_watermark(&wm).expect_err("opacity 0 should be rejected");
2573 assert_eq!(
2574 err,
2575 TransformError::InvalidOptions(
2576 "watermark opacity must be between 1 and 100".to_string()
2577 )
2578 );
2579 }
2580
2581 #[test]
2582 fn validate_watermark_rejects_opacity_above_100() {
2583 let wm = super::WatermarkInput {
2584 image: jpeg_artifact(),
2585 position: Position::BottomRight,
2586 opacity: 101,
2587 margin: 10,
2588 };
2589 let err = super::validate_watermark(&wm).expect_err("opacity 101 should be rejected");
2590 assert_eq!(
2591 err,
2592 TransformError::InvalidOptions(
2593 "watermark opacity must be between 1 and 100".to_string()
2594 )
2595 );
2596 }
2597
2598 #[test]
2599 fn validate_watermark_rejects_svg_image() {
2600 let wm = super::WatermarkInput {
2601 image: Artifact::new(vec![1], MediaType::Svg, ArtifactMetadata::default()),
2602 position: Position::BottomRight,
2603 opacity: 50,
2604 margin: 10,
2605 };
2606 let err = super::validate_watermark(&wm).expect_err("SVG watermark should be rejected");
2607 assert_eq!(
2608 err,
2609 TransformError::InvalidOptions("watermark image must be a raster format".to_string())
2610 );
2611 }
2612
2613 #[test]
2614 fn validate_watermark_accepts_valid_input() {
2615 let wm = super::WatermarkInput {
2616 image: jpeg_artifact(),
2617 position: Position::BottomRight,
2618 opacity: 50,
2619 margin: 10,
2620 };
2621 super::validate_watermark(&wm).expect("valid watermark should be accepted");
2622 }
2623
2624 #[test]
2625 fn crop_region_from_str_valid() {
2626 use super::CropRegion;
2627 let crop: CropRegion = "10,20,100,200".parse().expect("valid crop");
2628 assert_eq!(crop.x, 10);
2629 assert_eq!(crop.y, 20);
2630 assert_eq!(crop.width, 100);
2631 assert_eq!(crop.height, 200);
2632 }
2633
2634 #[test]
2635 fn crop_region_from_str_zero_width() {
2636 use super::CropRegion;
2637 let err = "10,20,0,200"
2638 .parse::<CropRegion>()
2639 .expect_err("zero width should fail");
2640 assert!(err.contains("greater than zero"), "unexpected error: {err}");
2641 }
2642
2643 #[test]
2644 fn crop_region_from_str_wrong_parts() {
2645 use super::CropRegion;
2646 let err = "10,20,100"
2647 .parse::<CropRegion>()
2648 .expect_err("three parts should fail");
2649 assert!(
2650 err.contains("four comma-separated"),
2651 "unexpected error: {err}"
2652 );
2653 }
2654
2655 #[test]
2656 fn crop_region_display() {
2657 use super::CropRegion;
2658 let crop = CropRegion {
2659 x: 1,
2660 y: 2,
2661 width: 3,
2662 height: 4,
2663 };
2664 assert_eq!(crop.to_string(), "1,2,3,4");
2665 }
2666
2667 #[test]
2668 fn normalize_rejects_zero_dimension_crop() {
2669 use super::{CropRegion, MediaType, TransformOptions};
2670 let opts = TransformOptions {
2671 crop: Some(CropRegion {
2672 x: 0,
2673 y: 0,
2674 width: 0,
2675 height: 100,
2676 }),
2677 ..TransformOptions::default()
2678 };
2679 let err = opts
2680 .normalize(MediaType::Jpeg)
2681 .expect_err("zero-width crop should fail");
2682 assert!(
2683 matches!(err, super::TransformError::InvalidOptions(_)),
2684 "unexpected error: {err:?}"
2685 );
2686 }
2687}