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
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RawArtifact {
31 pub bytes: Vec<u8>,
33 pub declared_media_type: Option<MediaType>,
35}
36
37impl RawArtifact {
38 pub fn new(bytes: Vec<u8>, declared_media_type: Option<MediaType>) -> Self {
40 Self {
41 bytes,
42 declared_media_type,
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Artifact {
50 pub bytes: Vec<u8>,
52 pub media_type: MediaType,
54 pub metadata: ArtifactMetadata,
56}
57
58impl Artifact {
59 pub fn new(bytes: Vec<u8>, media_type: MediaType, metadata: ArtifactMetadata) -> Self {
61 Self {
62 bytes,
63 media_type,
64 metadata,
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct ArtifactMetadata {
72 pub width: Option<u32>,
74 pub height: Option<u32>,
76 pub frame_count: u32,
78 pub duration: Option<Duration>,
80 pub has_alpha: Option<bool>,
82}
83
84impl Default for ArtifactMetadata {
85 fn default() -> Self {
86 Self {
87 width: None,
88 height: None,
89 frame_count: 1,
90 duration: None,
91 has_alpha: None,
92 }
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum MediaType {
99 Jpeg,
101 Png,
103 Webp,
105 Avif,
107 Svg,
109 Bmp,
111}
112
113impl MediaType {
114 pub const fn as_name(self) -> &'static str {
116 match self {
117 Self::Jpeg => "jpeg",
118 Self::Png => "png",
119 Self::Webp => "webp",
120 Self::Avif => "avif",
121 Self::Svg => "svg",
122 Self::Bmp => "bmp",
123 }
124 }
125
126 pub const fn as_mime(self) -> &'static str {
128 match self {
129 Self::Jpeg => "image/jpeg",
130 Self::Png => "image/png",
131 Self::Webp => "image/webp",
132 Self::Avif => "image/avif",
133 Self::Svg => "image/svg+xml",
134 Self::Bmp => "image/bmp",
135 }
136 }
137
138 pub const fn is_lossy(self) -> bool {
140 matches!(self, Self::Jpeg | Self::Webp | Self::Avif)
141 }
142
143 pub const fn is_raster(self) -> bool {
145 !matches!(self, Self::Svg)
146 }
147}
148
149impl fmt::Display for MediaType {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 f.write_str(self.as_mime())
152 }
153}
154
155impl FromStr for MediaType {
156 type Err = String;
157
158 fn from_str(value: &str) -> Result<Self, Self::Err> {
159 match value {
160 "jpeg" | "jpg" => Ok(Self::Jpeg),
161 "png" => Ok(Self::Png),
162 "webp" => Ok(Self::Webp),
163 "avif" => Ok(Self::Avif),
164 "svg" => Ok(Self::Svg),
165 "bmp" => Ok(Self::Bmp),
166 _ => Err(format!("unsupported media type `{value}`")),
167 }
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct WatermarkInput {
189 pub image: Artifact,
191 pub position: Position,
193 pub opacity: u8,
195 pub margin: u32,
197}
198
199#[derive(Debug, Clone, PartialEq)]
201pub struct TransformRequest {
202 pub input: Artifact,
204 pub options: TransformOptions,
206 pub watermark: Option<WatermarkInput>,
208}
209
210impl TransformRequest {
211 pub fn new(input: Artifact, options: TransformOptions) -> Self {
213 Self {
214 input,
215 options,
216 watermark: None,
217 }
218 }
219
220 pub fn with_watermark(
222 input: Artifact,
223 options: TransformOptions,
224 watermark: WatermarkInput,
225 ) -> Self {
226 Self {
227 input,
228 options,
229 watermark: Some(watermark),
230 }
231 }
232
233 pub fn normalize(self) -> Result<NormalizedTransformRequest, TransformError> {
235 let options = self.options.normalize(self.input.media_type)?;
236
237 if let Some(ref wm) = self.watermark {
238 validate_watermark(wm)?;
239 }
240
241 Ok(NormalizedTransformRequest {
242 input: self.input,
243 options,
244 watermark: self.watermark,
245 })
246 }
247}
248
249#[derive(Debug, Clone, PartialEq)]
251pub struct NormalizedTransformRequest {
252 pub input: Artifact,
254 pub options: NormalizedTransformOptions,
256 pub watermark: Option<WatermarkInput>,
258}
259
260#[derive(Debug, Clone, PartialEq)]
262pub struct TransformOptions {
263 pub width: Option<u32>,
265 pub height: Option<u32>,
267 pub fit: Option<Fit>,
269 pub position: Option<Position>,
271 pub format: Option<MediaType>,
273 pub quality: Option<u8>,
275 pub background: Option<Rgba8>,
277 pub rotate: Rotation,
279 pub auto_orient: bool,
281 pub strip_metadata: bool,
283 pub preserve_exif: bool,
285 pub blur: Option<f32>,
290 pub deadline: Option<Duration>,
297}
298
299impl Default for TransformOptions {
300 fn default() -> Self {
301 Self {
302 width: None,
303 height: None,
304 fit: None,
305 position: None,
306 format: None,
307 quality: None,
308 background: None,
309 rotate: Rotation::Deg0,
310 auto_orient: true,
311 strip_metadata: true,
312 preserve_exif: false,
313 blur: None,
314 deadline: None,
315 }
316 }
317}
318
319impl TransformOptions {
320 pub fn normalize(
322 self,
323 input_media_type: MediaType,
324 ) -> Result<NormalizedTransformOptions, TransformError> {
325 validate_dimension("width", self.width)?;
326 validate_dimension("height", self.height)?;
327 validate_quality(self.quality)?;
328 validate_blur(self.blur)?;
329
330 let has_bounded_resize = self.width.is_some() && self.height.is_some();
331
332 if self.fit.is_some() && !has_bounded_resize {
333 return Err(TransformError::InvalidOptions(
334 "fit requires both width and height".to_string(),
335 ));
336 }
337
338 if self.position.is_some() && !has_bounded_resize {
339 return Err(TransformError::InvalidOptions(
340 "position requires both width and height".to_string(),
341 ));
342 }
343
344 if self.preserve_exif && self.strip_metadata {
345 return Err(TransformError::InvalidOptions(
346 "preserve_exif requires strip_metadata to be false".to_string(),
347 ));
348 }
349
350 let format = self.format.unwrap_or(input_media_type);
351
352 if self.preserve_exif && format == MediaType::Svg {
353 return Err(TransformError::InvalidOptions(
354 "preserveExif is not supported with SVG output".to_string(),
355 ));
356 }
357
358 if self.quality.is_some() && !format.is_lossy() {
359 return Err(TransformError::InvalidOptions(
360 "quality requires a lossy output format".to_string(),
361 ));
362 }
363
364 let fit = if has_bounded_resize {
365 Some(self.fit.unwrap_or(Fit::Contain))
366 } else {
367 None
368 };
369
370 Ok(NormalizedTransformOptions {
371 width: self.width,
372 height: self.height,
373 fit,
374 position: self.position.unwrap_or(Position::Center),
375 format,
376 quality: self.quality,
377 background: self.background,
378 rotate: self.rotate,
379 auto_orient: self.auto_orient,
380 metadata_policy: normalize_metadata_policy(self.strip_metadata, self.preserve_exif),
381 blur: self.blur,
382 deadline: self.deadline,
383 })
384 }
385}
386
387#[derive(Debug, Clone, PartialEq)]
389pub struct NormalizedTransformOptions {
390 pub width: Option<u32>,
392 pub height: Option<u32>,
394 pub fit: Option<Fit>,
396 pub position: Position,
398 pub format: MediaType,
400 pub quality: Option<u8>,
402 pub background: Option<Rgba8>,
404 pub rotate: Rotation,
406 pub auto_orient: bool,
408 pub metadata_policy: MetadataPolicy,
410 pub blur: Option<f32>,
412 pub deadline: Option<Duration>,
414}
415
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418pub enum Fit {
419 Contain,
421 Cover,
423 Fill,
425 Inside,
427}
428
429impl Fit {
430 pub const fn as_name(self) -> &'static str {
432 match self {
433 Self::Contain => "contain",
434 Self::Cover => "cover",
435 Self::Fill => "fill",
436 Self::Inside => "inside",
437 }
438 }
439}
440
441impl FromStr for Fit {
442 type Err = String;
443
444 fn from_str(value: &str) -> Result<Self, Self::Err> {
445 match value {
446 "contain" => Ok(Self::Contain),
447 "cover" => Ok(Self::Cover),
448 "fill" => Ok(Self::Fill),
449 "inside" => Ok(Self::Inside),
450 _ => Err(format!("unsupported fit mode `{value}`")),
451 }
452 }
453}
454
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457pub enum Position {
458 Center,
460 Top,
462 Right,
464 Bottom,
466 Left,
468 TopLeft,
470 TopRight,
472 BottomLeft,
474 BottomRight,
476}
477
478impl Position {
479 pub const fn as_name(self) -> &'static str {
481 match self {
482 Self::Center => "center",
483 Self::Top => "top",
484 Self::Right => "right",
485 Self::Bottom => "bottom",
486 Self::Left => "left",
487 Self::TopLeft => "top-left",
488 Self::TopRight => "top-right",
489 Self::BottomLeft => "bottom-left",
490 Self::BottomRight => "bottom-right",
491 }
492 }
493}
494
495impl FromStr for Position {
496 type Err = String;
497
498 fn from_str(value: &str) -> Result<Self, Self::Err> {
499 match value {
500 "center" => Ok(Self::Center),
501 "top" => Ok(Self::Top),
502 "right" => Ok(Self::Right),
503 "bottom" => Ok(Self::Bottom),
504 "left" => Ok(Self::Left),
505 "top-left" => Ok(Self::TopLeft),
506 "top-right" => Ok(Self::TopRight),
507 "bottom-left" => Ok(Self::BottomLeft),
508 "bottom-right" => Ok(Self::BottomRight),
509 _ => Err(format!("unsupported position `{value}`")),
510 }
511 }
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq)]
516pub enum Rotation {
517 Deg0,
519 Deg90,
521 Deg180,
523 Deg270,
525}
526
527impl Rotation {
528 pub const fn as_degrees(self) -> u16 {
530 match self {
531 Self::Deg0 => 0,
532 Self::Deg90 => 90,
533 Self::Deg180 => 180,
534 Self::Deg270 => 270,
535 }
536 }
537}
538
539impl FromStr for Rotation {
540 type Err = String;
541
542 fn from_str(value: &str) -> Result<Self, Self::Err> {
543 match value {
544 "0" => Ok(Self::Deg0),
545 "90" => Ok(Self::Deg90),
546 "180" => Ok(Self::Deg180),
547 "270" => Ok(Self::Deg270),
548 _ => Err(format!("unsupported rotation `{value}`")),
549 }
550 }
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub struct Rgba8 {
556 pub r: u8,
558 pub g: u8,
560 pub b: u8,
562 pub a: u8,
564}
565
566impl Rgba8 {
567 pub fn from_hex(value: &str) -> Result<Self, String> {
569 if value.len() != 6 && value.len() != 8 {
570 return Err(format!("unsupported color `{value}`"));
571 }
572
573 let r = u8::from_str_radix(&value[0..2], 16)
574 .map_err(|_| format!("unsupported color `{value}`"))?;
575 let g = u8::from_str_radix(&value[2..4], 16)
576 .map_err(|_| format!("unsupported color `{value}`"))?;
577 let b = u8::from_str_radix(&value[4..6], 16)
578 .map_err(|_| format!("unsupported color `{value}`"))?;
579 let a = if value.len() == 8 {
580 u8::from_str_radix(&value[6..8], 16)
581 .map_err(|_| format!("unsupported color `{value}`"))?
582 } else {
583 u8::MAX
584 };
585
586 Ok(Self { r, g, b, a })
587 }
588}
589
590#[derive(Debug, Clone, Copy, PartialEq, Eq)]
592pub enum MetadataPolicy {
593 StripAll,
595 KeepAll,
597 PreserveExif,
599}
600
601pub fn resolve_metadata_flags(
644 strip: Option<bool>,
645 keep: Option<bool>,
646 preserve_exif: Option<bool>,
647) -> Result<(bool, bool), TransformError> {
648 let keep = keep.unwrap_or(false);
649 let preserve_exif = preserve_exif.unwrap_or(false);
650
651 if keep && preserve_exif {
652 return Err(TransformError::InvalidOptions(
653 "keepMetadata and preserveExif cannot both be true".to_string(),
654 ));
655 }
656
657 let strip_metadata = if keep || preserve_exif {
658 false
659 } else {
660 strip.unwrap_or(true)
661 };
662
663 Ok((strip_metadata, preserve_exif))
664}
665
666#[derive(Debug, Clone, PartialEq, Eq)]
668pub enum TransformError {
669 InvalidInput(String),
671 InvalidOptions(String),
673 UnsupportedInputMediaType(String),
675 UnsupportedOutputMediaType(MediaType),
677 DecodeFailed(String),
679 EncodeFailed(String),
681 CapabilityMissing(String),
683 LimitExceeded(String),
685}
686
687impl fmt::Display for TransformError {
688 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
689 match self {
690 Self::InvalidInput(reason) => write!(f, "invalid input: {reason}"),
691 Self::InvalidOptions(reason) => write!(f, "invalid transform options: {reason}"),
692 Self::UnsupportedInputMediaType(reason) => {
693 write!(f, "unsupported input media type: {reason}")
694 }
695 Self::UnsupportedOutputMediaType(media_type) => {
696 write!(f, "unsupported output media type: {media_type}")
697 }
698 Self::DecodeFailed(reason) => write!(f, "decode failed: {reason}"),
699 Self::EncodeFailed(reason) => write!(f, "encode failed: {reason}"),
700 Self::CapabilityMissing(reason) => write!(f, "missing capability: {reason}"),
701 Self::LimitExceeded(reason) => write!(f, "limit exceeded: {reason}"),
702 }
703 }
704}
705
706impl Error for TransformError {}
707
708#[derive(Debug, Clone, Copy, PartialEq, Eq)]
722pub enum MetadataKind {
723 Xmp,
725 Iptc,
727 Exif,
729 Icc,
731}
732
733impl fmt::Display for MetadataKind {
734 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
735 match self {
736 Self::Xmp => f.write_str("XMP"),
737 Self::Iptc => f.write_str("IPTC"),
738 Self::Exif => f.write_str("EXIF"),
739 Self::Icc => f.write_str("ICC profile"),
740 }
741 }
742}
743
744#[derive(Debug, Clone, PartialEq, Eq)]
760pub enum TransformWarning {
761 MetadataDropped(MetadataKind),
764}
765
766impl fmt::Display for TransformWarning {
767 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
768 match self {
769 Self::MetadataDropped(kind) => write!(
770 f,
771 "{kind} metadata was present in the input but could not be preserved by the output encoder"
772 ),
773 }
774 }
775}
776
777#[derive(Debug)]
782pub struct TransformResult {
783 pub artifact: Artifact,
785 pub warnings: Vec<TransformWarning>,
787}
788
789pub fn sniff_artifact(input: RawArtifact) -> Result<Artifact, TransformError> {
845 let (media_type, metadata) = detect_artifact(&input.bytes)?;
846
847 if let Some(declared_media_type) = input.declared_media_type
848 && declared_media_type != media_type
849 {
850 return Err(TransformError::InvalidInput(
851 "declared media type does not match detected media type".to_string(),
852 ));
853 }
854
855 Ok(Artifact::new(input.bytes, media_type, metadata))
856}
857
858fn validate_dimension(name: &str, value: Option<u32>) -> Result<(), TransformError> {
859 if matches!(value, Some(0)) {
860 return Err(TransformError::InvalidOptions(format!(
861 "{name} must be greater than zero"
862 )));
863 }
864
865 Ok(())
866}
867
868fn validate_quality(value: Option<u8>) -> Result<(), TransformError> {
869 if matches!(value, Some(0) | Some(101..=u8::MAX)) {
870 return Err(TransformError::InvalidOptions(
871 "quality must be between 1 and 100".to_string(),
872 ));
873 }
874
875 Ok(())
876}
877
878fn validate_blur(value: Option<f32>) -> Result<(), TransformError> {
879 if let Some(sigma) = value
880 && !(0.1..=100.0).contains(&sigma)
881 {
882 return Err(TransformError::InvalidOptions(
883 "blur sigma must be between 0.1 and 100.0".to_string(),
884 ));
885 }
886
887 Ok(())
888}
889
890fn validate_watermark(wm: &WatermarkInput) -> Result<(), TransformError> {
891 if wm.opacity == 0 || wm.opacity > 100 {
892 return Err(TransformError::InvalidOptions(
893 "watermark opacity must be between 1 and 100".to_string(),
894 ));
895 }
896
897 if !wm.image.media_type.is_raster() {
898 return Err(TransformError::InvalidOptions(
899 "watermark image must be a raster format".to_string(),
900 ));
901 }
902
903 Ok(())
904}
905
906fn normalize_metadata_policy(strip_metadata: bool, preserve_exif: bool) -> MetadataPolicy {
907 if preserve_exif {
908 MetadataPolicy::PreserveExif
909 } else if strip_metadata {
910 MetadataPolicy::StripAll
911 } else {
912 MetadataPolicy::KeepAll
913 }
914}
915
916fn detect_artifact(bytes: &[u8]) -> Result<(MediaType, ArtifactMetadata), TransformError> {
917 if is_png(bytes) {
918 return Ok((MediaType::Png, sniff_png(bytes)?));
919 }
920
921 if is_jpeg(bytes) {
922 return Ok((MediaType::Jpeg, sniff_jpeg(bytes)?));
923 }
924
925 if is_webp(bytes) {
926 return Ok((MediaType::Webp, sniff_webp(bytes)?));
927 }
928
929 if is_avif(bytes) {
930 return Ok((MediaType::Avif, sniff_avif(bytes)?));
931 }
932
933 if is_bmp(bytes) {
934 return Ok((MediaType::Bmp, sniff_bmp(bytes)?));
935 }
936
937 if is_svg(bytes) {
940 return Ok((MediaType::Svg, sniff_svg(bytes)));
941 }
942
943 Err(TransformError::UnsupportedInputMediaType(
944 "unknown file signature".to_string(),
945 ))
946}
947
948fn is_png(bytes: &[u8]) -> bool {
949 bytes.starts_with(b"\x89PNG\r\n\x1a\n")
950}
951
952fn is_jpeg(bytes: &[u8]) -> bool {
953 bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
954}
955
956fn is_webp(bytes: &[u8]) -> bool {
957 bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP"
958}
959
960fn is_avif(bytes: &[u8]) -> bool {
961 bytes.len() >= 16 && &bytes[4..8] == b"ftyp" && has_avif_brand(&bytes[8..])
962}
963
964fn is_svg(bytes: &[u8]) -> bool {
967 let text = match std::str::from_utf8(bytes) {
968 Ok(s) => s,
969 Err(_) => return false,
970 };
971
972 let mut remaining = text.trim_start();
973
974 remaining = remaining.strip_prefix('\u{FEFF}').unwrap_or(remaining);
976 remaining = remaining.trim_start();
977
978 if let Some(rest) = remaining.strip_prefix("<?xml") {
980 if let Some(end) = rest.find("?>") {
981 remaining = rest[end + 2..].trim_start();
982 } else {
983 return false;
984 }
985 }
986
987 if let Some(rest) = remaining.strip_prefix("<!DOCTYPE") {
989 if let Some(end) = rest.find('>') {
990 remaining = rest[end + 1..].trim_start();
991 } else {
992 return false;
993 }
994 }
995
996 while let Some(rest) = remaining.strip_prefix("<!--") {
998 if let Some(end) = rest.find("-->") {
999 remaining = rest[end + 3..].trim_start();
1000 } else {
1001 return false;
1002 }
1003 }
1004
1005 remaining.starts_with("<svg")
1006 && remaining
1007 .as_bytes()
1008 .get(4)
1009 .is_some_and(|&b| b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'>')
1010}
1011
1012fn sniff_svg(_bytes: &[u8]) -> ArtifactMetadata {
1016 ArtifactMetadata {
1017 width: None,
1018 height: None,
1019 frame_count: 1,
1020 duration: None,
1021 has_alpha: Some(true),
1022 }
1023}
1024
1025fn is_bmp(bytes: &[u8]) -> bool {
1027 bytes.len() >= 26 && bytes[0] == 0x42 && bytes[1] == 0x4D
1028}
1029
1030fn sniff_bmp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1037 if bytes.len() < 30 {
1038 return Err(TransformError::DecodeFailed(
1039 "bmp file is too short".to_string(),
1040 ));
1041 }
1042
1043 let width = u32::from_le_bytes([bytes[18], bytes[19], bytes[20], bytes[21]]);
1044 let raw_height = i32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]);
1045 let height = raw_height.unsigned_abs();
1046 let bits_per_pixel = u16::from_le_bytes([bytes[28], bytes[29]]);
1047
1048 let has_alpha = bits_per_pixel == 32;
1049
1050 Ok(ArtifactMetadata {
1051 width: Some(width),
1052 height: Some(height),
1053 frame_count: 1,
1054 duration: None,
1055 has_alpha: Some(has_alpha),
1056 })
1057}
1058
1059fn sniff_png(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1060 if bytes.len() < 29 {
1061 return Err(TransformError::DecodeFailed(
1062 "png file is too short".to_string(),
1063 ));
1064 }
1065
1066 if &bytes[12..16] != b"IHDR" {
1067 return Err(TransformError::DecodeFailed(
1068 "png file is missing an IHDR chunk".to_string(),
1069 ));
1070 }
1071
1072 let width = read_u32_be(&bytes[16..20])?;
1073 let height = read_u32_be(&bytes[20..24])?;
1074 let color_type = bytes[25];
1075 let has_alpha = match color_type {
1076 4 | 6 => Some(true),
1077 0 | 2 | 3 => Some(false),
1078 _ => None,
1079 };
1080
1081 Ok(ArtifactMetadata {
1082 width: Some(width),
1083 height: Some(height),
1084 frame_count: 1,
1085 duration: None,
1086 has_alpha,
1087 })
1088}
1089
1090fn sniff_jpeg(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1091 let mut offset = 2;
1092
1093 while offset + 1 < bytes.len() {
1094 if bytes[offset] != 0xFF {
1095 return Err(TransformError::DecodeFailed(
1096 "jpeg file has an invalid marker prefix".to_string(),
1097 ));
1098 }
1099
1100 while offset < bytes.len() && bytes[offset] == 0xFF {
1101 offset += 1;
1102 }
1103
1104 if offset >= bytes.len() {
1105 break;
1106 }
1107
1108 let marker = bytes[offset];
1109 offset += 1;
1110
1111 if marker == 0xD9 || marker == 0xDA {
1112 break;
1113 }
1114
1115 if (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
1116 continue;
1117 }
1118
1119 if offset + 2 > bytes.len() {
1120 return Err(TransformError::DecodeFailed(
1121 "jpeg segment is truncated".to_string(),
1122 ));
1123 }
1124
1125 let segment_length = read_u16_be(&bytes[offset..offset + 2])? as usize;
1126 if segment_length < 2 || offset + segment_length > bytes.len() {
1127 return Err(TransformError::DecodeFailed(
1128 "jpeg segment length is invalid".to_string(),
1129 ));
1130 }
1131
1132 if is_jpeg_sof_marker(marker) {
1133 if segment_length < 7 {
1134 return Err(TransformError::DecodeFailed(
1135 "jpeg SOF segment is too short".to_string(),
1136 ));
1137 }
1138
1139 let height = read_u16_be(&bytes[offset + 3..offset + 5])? as u32;
1140 let width = read_u16_be(&bytes[offset + 5..offset + 7])? as u32;
1141
1142 return Ok(ArtifactMetadata {
1143 width: Some(width),
1144 height: Some(height),
1145 frame_count: 1,
1146 duration: None,
1147 has_alpha: Some(false),
1148 });
1149 }
1150
1151 offset += segment_length;
1152 }
1153
1154 Err(TransformError::DecodeFailed(
1155 "jpeg file is missing a SOF segment".to_string(),
1156 ))
1157}
1158
1159fn sniff_webp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1160 let mut offset = 12;
1161
1162 while offset + 8 <= bytes.len() {
1163 let chunk_tag = &bytes[offset..offset + 4];
1164 let chunk_size = read_u32_le(&bytes[offset + 4..offset + 8])? as usize;
1165 let chunk_start = offset + 8;
1166 let chunk_end = chunk_start
1167 .checked_add(chunk_size)
1168 .ok_or_else(|| TransformError::DecodeFailed("webp chunk is too large".to_string()))?;
1169
1170 if chunk_end > bytes.len() {
1171 return Err(TransformError::DecodeFailed(
1172 "webp chunk exceeds file length".to_string(),
1173 ));
1174 }
1175
1176 let chunk_data = &bytes[chunk_start..chunk_end];
1177
1178 match chunk_tag {
1179 b"VP8X" => return sniff_webp_vp8x(chunk_data),
1180 b"VP8 " => return sniff_webp_vp8(chunk_data),
1181 b"VP8L" => return sniff_webp_vp8l(chunk_data),
1182 _ => {}
1183 }
1184
1185 offset = chunk_end + (chunk_size % 2);
1186 }
1187
1188 Err(TransformError::DecodeFailed(
1189 "webp file is missing an image chunk".to_string(),
1190 ))
1191}
1192
1193fn sniff_webp_vp8x(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1194 if bytes.len() < 10 {
1195 return Err(TransformError::DecodeFailed(
1196 "webp VP8X chunk is too short".to_string(),
1197 ));
1198 }
1199
1200 let flags = bytes[0];
1201 let width = read_u24_le(&bytes[4..7])? + 1;
1202 let height = read_u24_le(&bytes[7..10])? + 1;
1203 let has_alpha = Some(flags & 0b0001_0000 != 0);
1204
1205 Ok(ArtifactMetadata {
1206 width: Some(width),
1207 height: Some(height),
1208 frame_count: 1,
1209 duration: None,
1210 has_alpha,
1211 })
1212}
1213
1214fn sniff_webp_vp8(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1215 if bytes.len() < 10 {
1216 return Err(TransformError::DecodeFailed(
1217 "webp VP8 chunk is too short".to_string(),
1218 ));
1219 }
1220
1221 if bytes[3..6] != [0x9D, 0x01, 0x2A] {
1222 return Err(TransformError::DecodeFailed(
1223 "webp VP8 chunk has an invalid start code".to_string(),
1224 ));
1225 }
1226
1227 let width = (read_u16_le(&bytes[6..8])? & 0x3FFF) as u32;
1228 let height = (read_u16_le(&bytes[8..10])? & 0x3FFF) as u32;
1229
1230 Ok(ArtifactMetadata {
1231 width: Some(width),
1232 height: Some(height),
1233 frame_count: 1,
1234 duration: None,
1235 has_alpha: Some(false),
1236 })
1237}
1238
1239fn sniff_webp_vp8l(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1240 if bytes.len() < 5 {
1241 return Err(TransformError::DecodeFailed(
1242 "webp VP8L chunk is too short".to_string(),
1243 ));
1244 }
1245
1246 if bytes[0] != 0x2F {
1247 return Err(TransformError::DecodeFailed(
1248 "webp VP8L chunk has an invalid signature".to_string(),
1249 ));
1250 }
1251
1252 let bits = read_u32_le(&bytes[1..5])?;
1253 let width = (bits & 0x3FFF) + 1;
1254 let height = ((bits >> 14) & 0x3FFF) + 1;
1255
1256 Ok(ArtifactMetadata {
1257 width: Some(width),
1258 height: Some(height),
1259 frame_count: 1,
1260 duration: None,
1261 has_alpha: None,
1262 })
1263}
1264
1265fn sniff_avif(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1266 if bytes.len() < 16 {
1267 return Err(TransformError::DecodeFailed(
1268 "avif file is too short".to_string(),
1269 ));
1270 }
1271
1272 if !has_avif_brand(&bytes[8..]) {
1273 return Err(TransformError::DecodeFailed(
1274 "avif file is missing a compatible AVIF brand".to_string(),
1275 ));
1276 }
1277
1278 let inspection = inspect_avif_container(bytes)?;
1279
1280 Ok(ArtifactMetadata {
1281 width: inspection.dimensions.map(|(width, _)| width),
1282 height: inspection.dimensions.map(|(_, height)| height),
1283 frame_count: 1,
1284 duration: None,
1285 has_alpha: inspection.has_alpha(),
1286 })
1287}
1288
1289fn has_avif_brand(bytes: &[u8]) -> bool {
1290 if bytes.len() < 8 {
1291 return false;
1292 }
1293
1294 if is_avif_brand(&bytes[0..4]) {
1295 return true;
1296 }
1297
1298 let mut offset = 8;
1299 while offset + 4 <= bytes.len() {
1300 if is_avif_brand(&bytes[offset..offset + 4]) {
1301 return true;
1302 }
1303 offset += 4;
1304 }
1305
1306 false
1307}
1308
1309fn is_avif_brand(bytes: &[u8]) -> bool {
1310 matches!(bytes, b"avif" | b"avis")
1311}
1312
1313const AVIF_ALPHA_AUX_TYPE: &[u8] = b"urn:mpeg:mpegB:cicp:systems:auxiliary:alpha";
1314
1315#[derive(Debug, Default)]
1316struct AvifInspection {
1317 dimensions: Option<(u32, u32)>,
1318 saw_structured_meta: bool,
1319 found_alpha_item: bool,
1320}
1321
1322impl AvifInspection {
1323 fn has_alpha(&self) -> Option<bool> {
1324 if self.saw_structured_meta {
1325 Some(self.found_alpha_item)
1326 } else {
1327 None
1328 }
1329 }
1330}
1331
1332fn inspect_avif_container(bytes: &[u8]) -> Result<AvifInspection, TransformError> {
1333 let mut inspection = AvifInspection::default();
1334 inspect_avif_boxes(bytes, &mut inspection)?;
1335 Ok(inspection)
1336}
1337
1338fn inspect_avif_boxes(bytes: &[u8], inspection: &mut AvifInspection) -> Result<(), TransformError> {
1339 let mut offset = 0;
1340
1341 while offset + 8 <= bytes.len() {
1342 let (box_type, payload, next_offset) = parse_mp4_box(bytes, offset)?;
1343
1344 match box_type {
1345 b"meta" | b"iref" => {
1346 inspection.saw_structured_meta = true;
1347 if payload.len() < 4 {
1348 return Err(TransformError::DecodeFailed(format!(
1349 "{} box is too short",
1350 String::from_utf8_lossy(box_type)
1351 )));
1352 }
1353 inspect_avif_boxes(&payload[4..], inspection)?;
1354 }
1355 b"iprp" | b"ipco" => {
1356 inspection.saw_structured_meta = true;
1357 inspect_avif_boxes(payload, inspection)?;
1358 }
1359 b"ispe" => {
1360 inspection.saw_structured_meta = true;
1361 if inspection.dimensions.is_none() {
1362 inspection.dimensions = Some(parse_avif_ispe(payload)?);
1363 }
1364 }
1365 b"auxC" => {
1366 inspection.saw_structured_meta = true;
1367 if avif_auxc_declares_alpha(payload)? {
1368 inspection.found_alpha_item = true;
1369 }
1370 }
1371 b"auxl" => {
1372 inspection.saw_structured_meta = true;
1373 inspection.found_alpha_item = true;
1374 }
1375 _ => {}
1376 }
1377
1378 offset = next_offset;
1379 }
1380
1381 if offset != bytes.len() {
1382 return Err(TransformError::DecodeFailed(
1383 "avif box payload has trailing bytes".to_string(),
1384 ));
1385 }
1386
1387 Ok(())
1388}
1389
1390fn parse_mp4_box(bytes: &[u8], offset: usize) -> Result<(&[u8; 4], &[u8], usize), TransformError> {
1391 if offset + 8 > bytes.len() {
1392 return Err(TransformError::DecodeFailed(
1393 "mp4 box header is truncated".to_string(),
1394 ));
1395 }
1396
1397 let size = read_u32_be(&bytes[offset..offset + 4])?;
1398 let box_type = bytes[offset + 4..offset + 8]
1399 .try_into()
1400 .map_err(|_| TransformError::DecodeFailed("expected 4-byte box type".to_string()))?;
1401 let mut header_len = 8_usize;
1402 let end = match size {
1403 0 => bytes.len(),
1404 1 => {
1405 if offset + 16 > bytes.len() {
1406 return Err(TransformError::DecodeFailed(
1407 "extended mp4 box header is truncated".to_string(),
1408 ));
1409 }
1410 header_len = 16;
1411 let extended_size = read_u64_be(&bytes[offset + 8..offset + 16])?;
1412 usize::try_from(extended_size)
1413 .map_err(|_| TransformError::DecodeFailed("mp4 box is too large".to_string()))?
1414 }
1415 _ => size as usize,
1416 };
1417
1418 if end < header_len {
1419 return Err(TransformError::DecodeFailed(
1420 "mp4 box size is smaller than its header".to_string(),
1421 ));
1422 }
1423
1424 let box_end = offset
1425 .checked_add(end)
1426 .ok_or_else(|| TransformError::DecodeFailed("mp4 box is too large".to_string()))?;
1427 if box_end > bytes.len() {
1428 return Err(TransformError::DecodeFailed(
1429 "mp4 box exceeds file length".to_string(),
1430 ));
1431 }
1432
1433 Ok((box_type, &bytes[offset + header_len..box_end], box_end))
1434}
1435
1436fn parse_avif_ispe(bytes: &[u8]) -> Result<(u32, u32), TransformError> {
1437 if bytes.len() < 12 {
1438 return Err(TransformError::DecodeFailed(
1439 "avif ispe box is too short".to_string(),
1440 ));
1441 }
1442
1443 let width = read_u32_be(&bytes[4..8])?;
1444 let height = read_u32_be(&bytes[8..12])?;
1445 Ok((width, height))
1446}
1447
1448fn avif_auxc_declares_alpha(bytes: &[u8]) -> Result<bool, TransformError> {
1449 if bytes.len() < 5 {
1450 return Err(TransformError::DecodeFailed(
1451 "avif auxC box is too short".to_string(),
1452 ));
1453 }
1454
1455 let urn = &bytes[4..];
1456 Ok(urn
1457 .strip_suffix(&[0])
1458 .is_some_and(|urn| urn == AVIF_ALPHA_AUX_TYPE))
1459}
1460
1461fn is_jpeg_sof_marker(marker: u8) -> bool {
1462 matches!(
1463 marker,
1464 0xC0 | 0xC1 | 0xC2 | 0xC3 | 0xC5 | 0xC6 | 0xC7 | 0xC9 | 0xCA | 0xCB | 0xCD | 0xCE | 0xCF
1465 )
1466}
1467
1468fn read_u16_be(bytes: &[u8]) -> Result<u16, TransformError> {
1469 let array: [u8; 2] = bytes
1470 .try_into()
1471 .map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
1472 Ok(u16::from_be_bytes(array))
1473}
1474
1475fn read_u16_le(bytes: &[u8]) -> Result<u16, TransformError> {
1476 let array: [u8; 2] = bytes
1477 .try_into()
1478 .map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
1479 Ok(u16::from_le_bytes(array))
1480}
1481
1482fn read_u24_le(bytes: &[u8]) -> Result<u32, TransformError> {
1483 if bytes.len() != 3 {
1484 return Err(TransformError::DecodeFailed("expected 3 bytes".to_string()));
1485 }
1486
1487 Ok(u32::from(bytes[0]) | (u32::from(bytes[1]) << 8) | (u32::from(bytes[2]) << 16))
1488}
1489
1490fn read_u32_be(bytes: &[u8]) -> Result<u32, TransformError> {
1491 let array: [u8; 4] = bytes
1492 .try_into()
1493 .map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
1494 Ok(u32::from_be_bytes(array))
1495}
1496
1497fn read_u32_le(bytes: &[u8]) -> Result<u32, TransformError> {
1498 let array: [u8; 4] = bytes
1499 .try_into()
1500 .map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
1501 Ok(u32::from_le_bytes(array))
1502}
1503
1504fn read_u64_be(bytes: &[u8]) -> Result<u64, TransformError> {
1505 let array: [u8; 8] = bytes
1506 .try_into()
1507 .map_err(|_| TransformError::DecodeFailed("expected 8 bytes".to_string()))?;
1508 Ok(u64::from_be_bytes(array))
1509}
1510
1511#[cfg(test)]
1512mod tests {
1513 use super::{
1514 Artifact, ArtifactMetadata, Fit, MediaType, MetadataPolicy, Position, RawArtifact, Rgba8,
1515 Rotation, TransformError, TransformOptions, TransformRequest, sniff_artifact,
1516 };
1517 use image::codecs::avif::AvifEncoder;
1518 use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
1519
1520 fn jpeg_artifact() -> Artifact {
1521 Artifact::new(vec![1, 2, 3], MediaType::Jpeg, ArtifactMetadata::default())
1522 }
1523
1524 fn png_bytes(width: u32, height: u32, color_type: u8) -> Vec<u8> {
1525 let mut bytes = Vec::new();
1526 bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
1527 bytes.extend_from_slice(&13_u32.to_be_bytes());
1528 bytes.extend_from_slice(b"IHDR");
1529 bytes.extend_from_slice(&width.to_be_bytes());
1530 bytes.extend_from_slice(&height.to_be_bytes());
1531 bytes.push(8);
1532 bytes.push(color_type);
1533 bytes.push(0);
1534 bytes.push(0);
1535 bytes.push(0);
1536 bytes.extend_from_slice(&0_u32.to_be_bytes());
1537 bytes
1538 }
1539
1540 fn jpeg_bytes(width: u16, height: u16) -> Vec<u8> {
1541 let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
1542 bytes.extend_from_slice(&[0; 14]);
1543 bytes.extend_from_slice(&[
1544 0xFF,
1545 0xC0,
1546 0x00,
1547 0x11,
1548 0x08,
1549 (height >> 8) as u8,
1550 height as u8,
1551 (width >> 8) as u8,
1552 width as u8,
1553 0x03,
1554 0x01,
1555 0x11,
1556 0x00,
1557 0x02,
1558 0x11,
1559 0x00,
1560 0x03,
1561 0x11,
1562 0x00,
1563 ]);
1564 bytes.extend_from_slice(&[0xFF, 0xD9]);
1565 bytes
1566 }
1567
1568 fn webp_vp8x_bytes(width: u32, height: u32, flags: u8) -> Vec<u8> {
1569 let width_minus_one = width - 1;
1570 let height_minus_one = height - 1;
1571 let mut bytes = Vec::new();
1572 bytes.extend_from_slice(b"RIFF");
1573 bytes.extend_from_slice(&30_u32.to_le_bytes());
1574 bytes.extend_from_slice(b"WEBP");
1575 bytes.extend_from_slice(b"VP8X");
1576 bytes.extend_from_slice(&10_u32.to_le_bytes());
1577 bytes.push(flags);
1578 bytes.extend_from_slice(&[0, 0, 0]);
1579 bytes.extend_from_slice(&[
1580 (width_minus_one & 0xFF) as u8,
1581 ((width_minus_one >> 8) & 0xFF) as u8,
1582 ((width_minus_one >> 16) & 0xFF) as u8,
1583 ]);
1584 bytes.extend_from_slice(&[
1585 (height_minus_one & 0xFF) as u8,
1586 ((height_minus_one >> 8) & 0xFF) as u8,
1587 ((height_minus_one >> 16) & 0xFF) as u8,
1588 ]);
1589 bytes
1590 }
1591
1592 fn webp_vp8l_bytes(width: u32, height: u32) -> Vec<u8> {
1593 let packed = (width - 1) | ((height - 1) << 14);
1594 let mut bytes = Vec::new();
1595 bytes.extend_from_slice(b"RIFF");
1596 bytes.extend_from_slice(&17_u32.to_le_bytes());
1597 bytes.extend_from_slice(b"WEBP");
1598 bytes.extend_from_slice(b"VP8L");
1599 bytes.extend_from_slice(&5_u32.to_le_bytes());
1600 bytes.push(0x2F);
1601 bytes.extend_from_slice(&packed.to_le_bytes());
1602 bytes.push(0);
1603 bytes
1604 }
1605
1606 fn avif_bytes() -> Vec<u8> {
1607 let mut bytes = Vec::new();
1608 bytes.extend_from_slice(&24_u32.to_be_bytes());
1609 bytes.extend_from_slice(b"ftyp");
1610 bytes.extend_from_slice(b"avif");
1611 bytes.extend_from_slice(&0_u32.to_be_bytes());
1612 bytes.extend_from_slice(b"mif1");
1613 bytes.extend_from_slice(b"avif");
1614 bytes
1615 }
1616
1617 fn encoded_avif_bytes(width: u32, height: u32, fill: Rgba<u8>) -> Vec<u8> {
1618 let image = RgbaImage::from_pixel(width, height, fill);
1619 let mut bytes = Vec::new();
1620 AvifEncoder::new(&mut bytes)
1621 .write_image(&image, width, height, ColorType::Rgba8.into())
1622 .expect("encode avif");
1623 bytes
1624 }
1625
1626 #[test]
1627 fn default_transform_options_match_documented_defaults() {
1628 let options = TransformOptions::default();
1629
1630 assert_eq!(options.width, None);
1631 assert_eq!(options.height, None);
1632 assert_eq!(options.fit, None);
1633 assert_eq!(options.position, None);
1634 assert_eq!(options.format, None);
1635 assert_eq!(options.quality, None);
1636 assert_eq!(options.rotate, Rotation::Deg0);
1637 assert!(options.auto_orient);
1638 assert!(options.strip_metadata);
1639 assert!(!options.preserve_exif);
1640 }
1641
1642 #[test]
1643 fn media_type_helpers_report_expected_values() {
1644 assert_eq!(MediaType::Jpeg.as_name(), "jpeg");
1645 assert_eq!(MediaType::Jpeg.as_mime(), "image/jpeg");
1646 assert!(MediaType::Webp.is_lossy());
1647 assert!(!MediaType::Png.is_lossy());
1648 }
1649
1650 #[test]
1651 fn media_type_parsing_accepts_documented_names() {
1652 assert_eq!("jpeg".parse::<MediaType>(), Ok(MediaType::Jpeg));
1653 assert_eq!("jpg".parse::<MediaType>(), Ok(MediaType::Jpeg));
1654 assert_eq!("png".parse::<MediaType>(), Ok(MediaType::Png));
1655 assert!("gif".parse::<MediaType>().is_err());
1656 }
1657
1658 #[test]
1659 fn fit_position_rotation_and_color_parsing_work() {
1660 assert_eq!("cover".parse::<Fit>(), Ok(Fit::Cover));
1661 assert_eq!(
1662 "bottom-right".parse::<Position>(),
1663 Ok(Position::BottomRight)
1664 );
1665 assert_eq!("270".parse::<Rotation>(), Ok(Rotation::Deg270));
1666 assert_eq!(
1667 Rgba8::from_hex("AABBCCDD"),
1668 Ok(Rgba8 {
1669 r: 0xAA,
1670 g: 0xBB,
1671 b: 0xCC,
1672 a: 0xDD
1673 })
1674 );
1675 assert!(Rgba8::from_hex("AABB").is_err());
1676 }
1677
1678 #[test]
1679 fn normalize_defaults_fit_and_position_for_bounded_resize() {
1680 let normalized = TransformOptions {
1681 width: Some(1200),
1682 height: Some(630),
1683 ..TransformOptions::default()
1684 }
1685 .normalize(MediaType::Jpeg)
1686 .expect("normalize bounded resize");
1687
1688 assert_eq!(normalized.fit, Some(Fit::Contain));
1689 assert_eq!(normalized.position, Position::Center);
1690 assert_eq!(normalized.format, MediaType::Jpeg);
1691 assert_eq!(normalized.metadata_policy, MetadataPolicy::StripAll);
1692 }
1693
1694 #[test]
1695 fn normalize_uses_requested_fit_and_output_format() {
1696 let normalized = TransformOptions {
1697 width: Some(320),
1698 height: Some(320),
1699 fit: Some(Fit::Cover),
1700 position: Some(Position::BottomRight),
1701 format: Some(MediaType::Webp),
1702 quality: Some(70),
1703 strip_metadata: false,
1704 preserve_exif: true,
1705 ..TransformOptions::default()
1706 }
1707 .normalize(MediaType::Jpeg)
1708 .expect("normalize explicit values");
1709
1710 assert_eq!(normalized.fit, Some(Fit::Cover));
1711 assert_eq!(normalized.position, Position::BottomRight);
1712 assert_eq!(normalized.format, MediaType::Webp);
1713 assert_eq!(normalized.quality, Some(70));
1714 assert_eq!(normalized.metadata_policy, MetadataPolicy::PreserveExif);
1715 }
1716
1717 #[test]
1718 fn normalize_can_keep_all_metadata() {
1719 let normalized = TransformOptions {
1720 strip_metadata: false,
1721 ..TransformOptions::default()
1722 }
1723 .normalize(MediaType::Jpeg)
1724 .expect("normalize keep metadata");
1725
1726 assert_eq!(normalized.metadata_policy, MetadataPolicy::KeepAll);
1727 }
1728
1729 #[test]
1730 fn normalize_keeps_fit_none_when_resize_is_not_bounded() {
1731 let normalized = TransformOptions {
1732 width: Some(500),
1733 ..TransformOptions::default()
1734 }
1735 .normalize(MediaType::Jpeg)
1736 .expect("normalize unbounded resize");
1737
1738 assert_eq!(normalized.fit, None);
1739 assert_eq!(normalized.position, Position::Center);
1740 }
1741
1742 #[test]
1743 fn normalize_rejects_zero_dimensions() {
1744 let err = TransformOptions {
1745 width: Some(0),
1746 ..TransformOptions::default()
1747 }
1748 .normalize(MediaType::Jpeg)
1749 .expect_err("zero width should fail");
1750
1751 assert_eq!(
1752 err,
1753 TransformError::InvalidOptions("width must be greater than zero".to_string())
1754 );
1755 }
1756
1757 #[test]
1758 fn normalize_rejects_fit_without_both_dimensions() {
1759 let err = TransformOptions {
1760 width: Some(300),
1761 fit: Some(Fit::Contain),
1762 ..TransformOptions::default()
1763 }
1764 .normalize(MediaType::Jpeg)
1765 .expect_err("fit without bounded resize should fail");
1766
1767 assert_eq!(
1768 err,
1769 TransformError::InvalidOptions("fit requires both width and height".to_string())
1770 );
1771 }
1772
1773 #[test]
1774 fn normalize_rejects_position_without_both_dimensions() {
1775 let err = TransformOptions {
1776 height: Some(300),
1777 position: Some(Position::Top),
1778 ..TransformOptions::default()
1779 }
1780 .normalize(MediaType::Jpeg)
1781 .expect_err("position without bounded resize should fail");
1782
1783 assert_eq!(
1784 err,
1785 TransformError::InvalidOptions("position requires both width and height".to_string())
1786 );
1787 }
1788
1789 #[test]
1790 fn normalize_rejects_quality_for_lossless_output() {
1791 let err = TransformOptions {
1792 format: Some(MediaType::Png),
1793 quality: Some(80),
1794 ..TransformOptions::default()
1795 }
1796 .normalize(MediaType::Jpeg)
1797 .expect_err("quality for png should fail");
1798
1799 assert_eq!(
1800 err,
1801 TransformError::InvalidOptions("quality requires a lossy output format".to_string())
1802 );
1803 }
1804
1805 #[test]
1806 fn normalize_rejects_zero_quality() {
1807 let err = TransformOptions {
1808 quality: Some(0),
1809 ..TransformOptions::default()
1810 }
1811 .normalize(MediaType::Jpeg)
1812 .expect_err("zero quality should fail");
1813
1814 assert_eq!(
1815 err,
1816 TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
1817 );
1818 }
1819
1820 #[test]
1821 fn normalize_rejects_quality_above_one_hundred() {
1822 let err = TransformOptions {
1823 quality: Some(101),
1824 ..TransformOptions::default()
1825 }
1826 .normalize(MediaType::Jpeg)
1827 .expect_err("quality above one hundred should fail");
1828
1829 assert_eq!(
1830 err,
1831 TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
1832 );
1833 }
1834
1835 #[test]
1836 fn normalize_rejects_preserve_exif_when_metadata_is_stripped() {
1837 let err = TransformOptions {
1838 preserve_exif: true,
1839 ..TransformOptions::default()
1840 }
1841 .normalize(MediaType::Jpeg)
1842 .expect_err("preserve_exif should require metadata retention");
1843
1844 assert_eq!(
1845 err,
1846 TransformError::InvalidOptions(
1847 "preserve_exif requires strip_metadata to be false".to_string()
1848 )
1849 );
1850 }
1851
1852 #[test]
1853 fn transform_request_normalize_uses_input_media_type_as_default_output() {
1854 let request = TransformRequest::new(jpeg_artifact(), TransformOptions::default());
1855 let normalized = request.normalize().expect("normalize request");
1856
1857 assert_eq!(normalized.input.media_type, MediaType::Jpeg);
1858 assert_eq!(normalized.options.format, MediaType::Jpeg);
1859 assert_eq!(normalized.options.metadata_policy, MetadataPolicy::StripAll);
1860 }
1861
1862 #[test]
1863 fn sniff_artifact_detects_png_dimensions_and_alpha() {
1864 let artifact =
1865 sniff_artifact(RawArtifact::new(png_bytes(64, 32, 6), None)).expect("sniff png");
1866
1867 assert_eq!(artifact.media_type, MediaType::Png);
1868 assert_eq!(artifact.metadata.width, Some(64));
1869 assert_eq!(artifact.metadata.height, Some(32));
1870 assert_eq!(artifact.metadata.has_alpha, Some(true));
1871 }
1872
1873 #[test]
1874 fn sniff_artifact_detects_jpeg_dimensions() {
1875 let artifact =
1876 sniff_artifact(RawArtifact::new(jpeg_bytes(320, 240), None)).expect("sniff jpeg");
1877
1878 assert_eq!(artifact.media_type, MediaType::Jpeg);
1879 assert_eq!(artifact.metadata.width, Some(320));
1880 assert_eq!(artifact.metadata.height, Some(240));
1881 assert_eq!(artifact.metadata.has_alpha, Some(false));
1882 }
1883
1884 #[test]
1885 fn sniff_artifact_detects_webp_vp8x_dimensions() {
1886 let artifact = sniff_artifact(RawArtifact::new(
1887 webp_vp8x_bytes(800, 600, 0b0001_0000),
1888 None,
1889 ))
1890 .expect("sniff webp vp8x");
1891
1892 assert_eq!(artifact.media_type, MediaType::Webp);
1893 assert_eq!(artifact.metadata.width, Some(800));
1894 assert_eq!(artifact.metadata.height, Some(600));
1895 assert_eq!(artifact.metadata.has_alpha, Some(true));
1896 }
1897
1898 #[test]
1899 fn sniff_artifact_detects_webp_vp8l_dimensions() {
1900 let artifact = sniff_artifact(RawArtifact::new(webp_vp8l_bytes(123, 77), None))
1901 .expect("sniff webp vp8l");
1902
1903 assert_eq!(artifact.media_type, MediaType::Webp);
1904 assert_eq!(artifact.metadata.width, Some(123));
1905 assert_eq!(artifact.metadata.height, Some(77));
1906 }
1907
1908 #[test]
1909 fn sniff_artifact_detects_avif_brand() {
1910 let artifact = sniff_artifact(RawArtifact::new(avif_bytes(), None)).expect("sniff avif");
1911
1912 assert_eq!(artifact.media_type, MediaType::Avif);
1913 assert_eq!(artifact.metadata, ArtifactMetadata::default());
1914 }
1915
1916 #[test]
1917 fn sniff_artifact_detects_avif_dimensions_and_alpha() {
1918 let artifact = sniff_artifact(RawArtifact::new(
1919 encoded_avif_bytes(7, 5, Rgba([10, 20, 30, 0])),
1920 None,
1921 ))
1922 .expect("sniff avif with alpha");
1923
1924 assert_eq!(artifact.media_type, MediaType::Avif);
1925 assert_eq!(artifact.metadata.width, Some(7));
1926 assert_eq!(artifact.metadata.height, Some(5));
1927 assert_eq!(artifact.metadata.has_alpha, Some(true));
1928 }
1929
1930 #[test]
1931 fn sniff_artifact_detects_opaque_avif_without_alpha_item() {
1932 let artifact = sniff_artifact(RawArtifact::new(
1933 encoded_avif_bytes(9, 4, Rgba([10, 20, 30, 255])),
1934 None,
1935 ))
1936 .expect("sniff opaque avif");
1937
1938 assert_eq!(artifact.media_type, MediaType::Avif);
1939 assert_eq!(artifact.metadata.width, Some(9));
1940 assert_eq!(artifact.metadata.height, Some(4));
1941 assert_eq!(artifact.metadata.has_alpha, Some(false));
1942 }
1943
1944 #[test]
1945 fn sniff_artifact_rejects_declared_media_type_mismatch() {
1946 let err = sniff_artifact(RawArtifact::new(png_bytes(8, 8, 2), Some(MediaType::Jpeg)))
1947 .expect_err("declared mismatch should fail");
1948
1949 assert_eq!(
1950 err,
1951 TransformError::InvalidInput(
1952 "declared media type does not match detected media type".to_string()
1953 )
1954 );
1955 }
1956
1957 #[test]
1958 fn sniff_artifact_rejects_unknown_signatures() {
1959 let err =
1960 sniff_artifact(RawArtifact::new(vec![1, 2, 3, 4], None)).expect_err("unknown bytes");
1961
1962 assert_eq!(
1963 err,
1964 TransformError::UnsupportedInputMediaType("unknown file signature".to_string())
1965 );
1966 }
1967
1968 #[test]
1969 fn sniff_artifact_rejects_invalid_png_structure() {
1970 let err = sniff_artifact(RawArtifact::new(b"\x89PNG\r\n\x1a\nbroken".to_vec(), None))
1971 .expect_err("broken png should fail");
1972
1973 assert_eq!(
1974 err,
1975 TransformError::DecodeFailed("png file is too short".to_string())
1976 );
1977 }
1978
1979 #[test]
1980 fn sniff_artifact_detects_bmp_dimensions() {
1981 let mut bmp = Vec::new();
1984 bmp.extend_from_slice(b"BM");
1986 bmp.extend_from_slice(&0u32.to_le_bytes());
1988 bmp.extend_from_slice(&0u32.to_le_bytes());
1990 bmp.extend_from_slice(&54u32.to_le_bytes());
1992 bmp.extend_from_slice(&40u32.to_le_bytes());
1994 bmp.extend_from_slice(&8u32.to_le_bytes());
1996 bmp.extend_from_slice(&6i32.to_le_bytes());
1998 bmp.extend_from_slice(&1u16.to_le_bytes());
2000 bmp.extend_from_slice(&24u16.to_le_bytes());
2002 bmp.resize(54, 0);
2004
2005 let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
2006 assert_eq!(artifact.media_type, MediaType::Bmp);
2007 assert_eq!(artifact.metadata.width, Some(8));
2008 assert_eq!(artifact.metadata.height, Some(6));
2009 assert_eq!(artifact.metadata.has_alpha, Some(false));
2010 }
2011
2012 #[test]
2013 fn sniff_artifact_detects_bmp_32bit_alpha() {
2014 let mut bmp = Vec::new();
2015 bmp.extend_from_slice(b"BM");
2016 bmp.extend_from_slice(&0u32.to_le_bytes());
2017 bmp.extend_from_slice(&0u32.to_le_bytes());
2018 bmp.extend_from_slice(&54u32.to_le_bytes());
2019 bmp.extend_from_slice(&40u32.to_le_bytes());
2020 bmp.extend_from_slice(&4u32.to_le_bytes());
2022 bmp.extend_from_slice(&4i32.to_le_bytes());
2024 bmp.extend_from_slice(&1u16.to_le_bytes());
2026 bmp.extend_from_slice(&32u16.to_le_bytes());
2028 bmp.resize(54, 0);
2029
2030 let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
2031 assert_eq!(artifact.media_type, MediaType::Bmp);
2032 assert_eq!(artifact.metadata.has_alpha, Some(true));
2033 }
2034
2035 #[test]
2036 fn sniff_artifact_rejects_too_short_bmp() {
2037 let mut data = b"BM".to_vec();
2039 data.resize(27, 0);
2040 let err =
2041 sniff_artifact(RawArtifact::new(data, None)).expect_err("too-short BMP should fail");
2042
2043 assert_eq!(
2044 err,
2045 TransformError::DecodeFailed("bmp file is too short".to_string())
2046 );
2047 }
2048
2049 #[test]
2050 fn normalize_rejects_blur_sigma_below_minimum() {
2051 let err = TransformOptions {
2052 blur: Some(0.0),
2053 ..TransformOptions::default()
2054 }
2055 .normalize(MediaType::Jpeg)
2056 .expect_err("blur sigma 0.0 should be rejected");
2057
2058 assert_eq!(
2059 err,
2060 TransformError::InvalidOptions("blur sigma must be between 0.1 and 100.0".to_string())
2061 );
2062 }
2063
2064 #[test]
2065 fn normalize_rejects_blur_sigma_above_maximum() {
2066 let err = TransformOptions {
2067 blur: Some(100.1),
2068 ..TransformOptions::default()
2069 }
2070 .normalize(MediaType::Jpeg)
2071 .expect_err("blur sigma 100.1 should be rejected");
2072
2073 assert_eq!(
2074 err,
2075 TransformError::InvalidOptions("blur sigma must be between 0.1 and 100.0".to_string())
2076 );
2077 }
2078
2079 #[test]
2080 fn normalize_accepts_blur_sigma_at_boundaries() {
2081 let opts_min = TransformOptions {
2082 blur: Some(0.1),
2083 ..TransformOptions::default()
2084 }
2085 .normalize(MediaType::Jpeg)
2086 .expect("blur sigma 0.1 should be accepted");
2087 assert_eq!(opts_min.blur, Some(0.1));
2088
2089 let opts_max = TransformOptions {
2090 blur: Some(100.0),
2091 ..TransformOptions::default()
2092 }
2093 .normalize(MediaType::Jpeg)
2094 .expect("blur sigma 100.0 should be accepted");
2095 assert_eq!(opts_max.blur, Some(100.0));
2096 }
2097
2098 #[test]
2099 fn validate_watermark_rejects_zero_opacity() {
2100 let wm = super::WatermarkInput {
2101 image: jpeg_artifact(),
2102 position: Position::BottomRight,
2103 opacity: 0,
2104 margin: 10,
2105 };
2106 let err = super::validate_watermark(&wm).expect_err("opacity 0 should be rejected");
2107 assert_eq!(
2108 err,
2109 TransformError::InvalidOptions(
2110 "watermark opacity must be between 1 and 100".to_string()
2111 )
2112 );
2113 }
2114
2115 #[test]
2116 fn validate_watermark_rejects_opacity_above_100() {
2117 let wm = super::WatermarkInput {
2118 image: jpeg_artifact(),
2119 position: Position::BottomRight,
2120 opacity: 101,
2121 margin: 10,
2122 };
2123 let err = super::validate_watermark(&wm).expect_err("opacity 101 should be rejected");
2124 assert_eq!(
2125 err,
2126 TransformError::InvalidOptions(
2127 "watermark opacity must be between 1 and 100".to_string()
2128 )
2129 );
2130 }
2131
2132 #[test]
2133 fn validate_watermark_rejects_svg_image() {
2134 let wm = super::WatermarkInput {
2135 image: Artifact::new(vec![1], MediaType::Svg, ArtifactMetadata::default()),
2136 position: Position::BottomRight,
2137 opacity: 50,
2138 margin: 10,
2139 };
2140 let err = super::validate_watermark(&wm).expect_err("SVG watermark should be rejected");
2141 assert_eq!(
2142 err,
2143 TransformError::InvalidOptions("watermark image must be a raster format".to_string())
2144 );
2145 }
2146
2147 #[test]
2148 fn validate_watermark_accepts_valid_input() {
2149 let wm = super::WatermarkInput {
2150 image: jpeg_artifact(),
2151 position: Position::BottomRight,
2152 opacity: 50,
2153 margin: 10,
2154 };
2155 super::validate_watermark(&wm).expect("valid watermark should be accepted");
2156 }
2157}