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, PartialEq, Eq)]
40pub struct RawArtifact {
41 pub bytes: Vec<u8>,
43 pub declared_media_type: Option<MediaType>,
45}
46
47impl RawArtifact {
48 pub fn new(bytes: Vec<u8>, declared_media_type: Option<MediaType>) -> Self {
50 Self {
51 bytes,
52 declared_media_type,
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Artifact {
60 pub bytes: Vec<u8>,
62 pub media_type: MediaType,
64 pub metadata: ArtifactMetadata,
66}
67
68impl Artifact {
69 pub fn new(bytes: Vec<u8>, media_type: MediaType, metadata: ArtifactMetadata) -> Self {
71 Self {
72 bytes,
73 media_type,
74 metadata,
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct ArtifactMetadata {
82 pub width: Option<u32>,
84 pub height: Option<u32>,
86 pub frame_count: u32,
88 pub duration: Option<Duration>,
90 pub has_alpha: Option<bool>,
92}
93
94impl Default for ArtifactMetadata {
95 fn default() -> Self {
96 Self {
97 width: None,
98 height: None,
99 frame_count: 1,
100 duration: None,
101 has_alpha: None,
102 }
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum MediaType {
109 Jpeg,
111 Png,
113 Webp,
115 Avif,
117 Svg,
119 Bmp,
121 Tiff,
123}
124
125impl MediaType {
126 pub const fn as_name(self) -> &'static str {
128 match self {
129 Self::Jpeg => "jpeg",
130 Self::Png => "png",
131 Self::Webp => "webp",
132 Self::Avif => "avif",
133 Self::Svg => "svg",
134 Self::Bmp => "bmp",
135 Self::Tiff => "tiff",
136 }
137 }
138
139 pub const fn as_mime(self) -> &'static str {
141 match self {
142 Self::Jpeg => "image/jpeg",
143 Self::Png => "image/png",
144 Self::Webp => "image/webp",
145 Self::Avif => "image/avif",
146 Self::Svg => "image/svg+xml",
147 Self::Bmp => "image/bmp",
148 Self::Tiff => "image/tiff",
149 }
150 }
151
152 pub const fn is_lossy(self) -> bool {
154 matches!(self, Self::Jpeg | Self::Webp | Self::Avif)
155 }
156
157 pub const fn is_raster(self) -> bool {
159 !matches!(self, Self::Svg)
160 }
161}
162
163impl fmt::Display for MediaType {
164 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165 f.write_str(self.as_mime())
166 }
167}
168
169impl FromStr for MediaType {
170 type Err = String;
171
172 fn from_str(value: &str) -> Result<Self, Self::Err> {
173 match value {
174 "jpeg" | "jpg" => Ok(Self::Jpeg),
175 "png" => Ok(Self::Png),
176 "webp" => Ok(Self::Webp),
177 "avif" => Ok(Self::Avif),
178 "svg" => Ok(Self::Svg),
179 "bmp" => Ok(Self::Bmp),
180 "tiff" | "tif" => Ok(Self::Tiff),
181 _ => Err(format!("unsupported media type `{value}`")),
182 }
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct WatermarkInput {
204 pub image: Artifact,
206 pub position: Position,
208 pub opacity: u8,
210 pub margin: u32,
212}
213
214#[derive(Debug, Clone, PartialEq)]
216pub struct TransformRequest {
217 pub input: Artifact,
219 pub options: TransformOptions,
221 pub watermark: Option<WatermarkInput>,
223}
224
225impl TransformRequest {
226 pub fn new(input: Artifact, options: TransformOptions) -> Self {
228 Self {
229 input,
230 options,
231 watermark: None,
232 }
233 }
234
235 pub fn with_watermark(
237 input: Artifact,
238 options: TransformOptions,
239 watermark: WatermarkInput,
240 ) -> Self {
241 Self {
242 input,
243 options,
244 watermark: Some(watermark),
245 }
246 }
247
248 pub fn normalize(self) -> Result<NormalizedTransformRequest, TransformError> {
250 let options = self.options.normalize(self.input.media_type)?;
251
252 if let Some(ref wm) = self.watermark {
253 validate_watermark(wm)?;
254 }
255
256 Ok(NormalizedTransformRequest {
257 input: self.input,
258 options,
259 watermark: self.watermark,
260 })
261 }
262}
263
264#[derive(Debug, Clone, PartialEq)]
266pub struct NormalizedTransformRequest {
267 pub input: Artifact,
269 pub options: NormalizedTransformOptions,
271 pub watermark: Option<WatermarkInput>,
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub struct CropRegion {
282 pub x: u32,
284 pub y: u32,
286 pub width: u32,
288 pub height: u32,
290}
291
292impl FromStr for CropRegion {
293 type Err = String;
294
295 fn from_str(s: &str) -> Result<Self, Self::Err> {
296 let parts: Vec<&str> = s.split(',').collect();
297 if parts.len() != 4 {
298 return Err(format!(
299 "crop must be x,y,w,h (four comma-separated integers), got '{s}'"
300 ));
301 }
302 let x = parts[0]
303 .parse::<u32>()
304 .map_err(|_| format!("crop x must be a non-negative integer, got '{}'", parts[0]))?;
305 let y = parts[1]
306 .parse::<u32>()
307 .map_err(|_| format!("crop y must be a non-negative integer, got '{}'", parts[1]))?;
308 let width = parts[2].parse::<u32>().map_err(|_| {
309 format!(
310 "crop width must be a non-negative integer, got '{}'",
311 parts[2]
312 )
313 })?;
314 let height = parts[3].parse::<u32>().map_err(|_| {
315 format!(
316 "crop height must be a non-negative integer, got '{}'",
317 parts[3]
318 )
319 })?;
320 if width == 0 || height == 0 {
321 return Err("crop width and height must be greater than zero".to_string());
322 }
323 Ok(CropRegion {
324 x,
325 y,
326 width,
327 height,
328 })
329 }
330}
331
332impl fmt::Display for CropRegion {
333 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334 write!(f, "{},{},{},{}", self.x, self.y, self.width, self.height)
335 }
336}
337
338#[derive(Debug, Clone, PartialEq)]
340pub struct TransformOptions {
341 pub width: Option<u32>,
343 pub height: Option<u32>,
345 pub fit: Option<Fit>,
347 pub position: Option<Position>,
349 pub format: Option<MediaType>,
351 pub quality: Option<u8>,
353 pub background: Option<Rgba8>,
355 pub rotate: Rotation,
357 pub auto_orient: bool,
359 pub strip_metadata: bool,
361 pub preserve_exif: bool,
363 pub blur: Option<f32>,
368 pub sharpen: Option<f32>,
374 pub crop: Option<CropRegion>,
380 pub deadline: Option<Duration>,
387}
388
389impl Default for TransformOptions {
390 fn default() -> Self {
391 Self {
392 width: None,
393 height: None,
394 fit: None,
395 position: None,
396 format: None,
397 quality: None,
398 background: None,
399 rotate: Rotation::Deg0,
400 auto_orient: true,
401 strip_metadata: true,
402 preserve_exif: false,
403 blur: None,
404 sharpen: None,
405 crop: None,
406 deadline: None,
407 }
408 }
409}
410
411impl TransformOptions {
412 pub fn normalize(
414 self,
415 input_media_type: MediaType,
416 ) -> Result<NormalizedTransformOptions, TransformError> {
417 validate_dimension("width", self.width)?;
418 validate_dimension("height", self.height)?;
419 validate_quality(self.quality)?;
420 validate_blur(self.blur)?;
421 validate_sharpen(self.sharpen)?;
422 if let Some(crop) = self.crop
423 && (crop.width == 0 || crop.height == 0)
424 {
425 return Err(TransformError::InvalidOptions(
426 "crop width and height must be greater than zero".to_string(),
427 ));
428 }
429
430 let has_bounded_resize = self.width.is_some() && self.height.is_some();
431
432 if self.fit.is_some() && !has_bounded_resize {
433 return Err(TransformError::InvalidOptions(
434 "fit requires both width and height".to_string(),
435 ));
436 }
437
438 if self.position.is_some() && !has_bounded_resize {
439 return Err(TransformError::InvalidOptions(
440 "position requires both width and height".to_string(),
441 ));
442 }
443
444 if self.preserve_exif && self.strip_metadata {
445 return Err(TransformError::InvalidOptions(
446 "preserve_exif requires strip_metadata to be false".to_string(),
447 ));
448 }
449
450 let format = self.format.unwrap_or(input_media_type);
451
452 if self.preserve_exif && format == MediaType::Svg {
453 return Err(TransformError::InvalidOptions(
454 "preserveExif is not supported with SVG output".to_string(),
455 ));
456 }
457
458 if self.quality.is_some() && !format.is_lossy() {
459 return Err(TransformError::InvalidOptions(
460 "quality requires a lossy output format".to_string(),
461 ));
462 }
463
464 let fit = if has_bounded_resize {
465 Some(self.fit.unwrap_or(Fit::Contain))
466 } else {
467 None
468 };
469
470 Ok(NormalizedTransformOptions {
471 width: self.width,
472 height: self.height,
473 fit,
474 position: self.position.unwrap_or(Position::Center),
475 format,
476 quality: self.quality,
477 background: self.background,
478 rotate: self.rotate,
479 auto_orient: self.auto_orient,
480 metadata_policy: normalize_metadata_policy(self.strip_metadata, self.preserve_exif),
481 blur: self.blur,
482 sharpen: self.sharpen,
483 crop: self.crop,
484 deadline: self.deadline,
485 })
486 }
487}
488
489#[derive(Debug, Clone, PartialEq)]
491pub struct NormalizedTransformOptions {
492 pub width: Option<u32>,
494 pub height: Option<u32>,
496 pub fit: Option<Fit>,
498 pub position: Position,
500 pub format: MediaType,
502 pub quality: Option<u8>,
504 pub background: Option<Rgba8>,
506 pub rotate: Rotation,
508 pub auto_orient: bool,
510 pub metadata_policy: MetadataPolicy,
512 pub blur: Option<f32>,
514 pub sharpen: Option<f32>,
516 pub crop: Option<CropRegion>,
518 pub deadline: Option<Duration>,
520}
521
522#[derive(Debug, Clone, Copy, PartialEq, Eq)]
524pub enum Fit {
525 Contain,
527 Cover,
529 Fill,
531 Inside,
533}
534
535impl Fit {
536 pub const fn as_name(self) -> &'static str {
538 match self {
539 Self::Contain => "contain",
540 Self::Cover => "cover",
541 Self::Fill => "fill",
542 Self::Inside => "inside",
543 }
544 }
545}
546
547impl FromStr for Fit {
548 type Err = String;
549
550 fn from_str(value: &str) -> Result<Self, Self::Err> {
551 match value {
552 "contain" => Ok(Self::Contain),
553 "cover" => Ok(Self::Cover),
554 "fill" => Ok(Self::Fill),
555 "inside" => Ok(Self::Inside),
556 _ => Err(format!("unsupported fit mode `{value}`")),
557 }
558 }
559}
560
561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
563pub enum Position {
564 Center,
566 Top,
568 Right,
570 Bottom,
572 Left,
574 TopLeft,
576 TopRight,
578 BottomLeft,
580 BottomRight,
582}
583
584impl Position {
585 pub const fn as_name(self) -> &'static str {
587 match self {
588 Self::Center => "center",
589 Self::Top => "top",
590 Self::Right => "right",
591 Self::Bottom => "bottom",
592 Self::Left => "left",
593 Self::TopLeft => "top-left",
594 Self::TopRight => "top-right",
595 Self::BottomLeft => "bottom-left",
596 Self::BottomRight => "bottom-right",
597 }
598 }
599}
600
601impl FromStr for Position {
602 type Err = String;
603
604 fn from_str(value: &str) -> Result<Self, Self::Err> {
605 match value {
606 "center" => Ok(Self::Center),
607 "top" => Ok(Self::Top),
608 "right" => Ok(Self::Right),
609 "bottom" => Ok(Self::Bottom),
610 "left" => Ok(Self::Left),
611 "top-left" => Ok(Self::TopLeft),
612 "top-right" => Ok(Self::TopRight),
613 "bottom-left" => Ok(Self::BottomLeft),
614 "bottom-right" => Ok(Self::BottomRight),
615 _ => Err(format!("unsupported position `{value}`")),
616 }
617 }
618}
619
620#[derive(Debug, Clone, Copy, PartialEq, Eq)]
622pub enum Rotation {
623 Deg0,
625 Deg90,
627 Deg180,
629 Deg270,
631}
632
633impl Rotation {
634 pub const fn as_degrees(self) -> u16 {
636 match self {
637 Self::Deg0 => 0,
638 Self::Deg90 => 90,
639 Self::Deg180 => 180,
640 Self::Deg270 => 270,
641 }
642 }
643}
644
645impl FromStr for Rotation {
646 type Err = String;
647
648 fn from_str(value: &str) -> Result<Self, Self::Err> {
649 match value {
650 "0" => Ok(Self::Deg0),
651 "90" => Ok(Self::Deg90),
652 "180" => Ok(Self::Deg180),
653 "270" => Ok(Self::Deg270),
654 _ => Err(format!("unsupported rotation `{value}`")),
655 }
656 }
657}
658
659#[derive(Debug, Clone, Copy, PartialEq, Eq)]
661pub struct Rgba8 {
662 pub r: u8,
664 pub g: u8,
666 pub b: u8,
668 pub a: u8,
670}
671
672impl Rgba8 {
673 pub fn from_hex(value: &str) -> Result<Self, String> {
675 if value.len() != 6 && value.len() != 8 {
676 return Err(format!("unsupported color `{value}`"));
677 }
678
679 let r = u8::from_str_radix(&value[0..2], 16)
680 .map_err(|_| format!("unsupported color `{value}`"))?;
681 let g = u8::from_str_radix(&value[2..4], 16)
682 .map_err(|_| format!("unsupported color `{value}`"))?;
683 let b = u8::from_str_radix(&value[4..6], 16)
684 .map_err(|_| format!("unsupported color `{value}`"))?;
685 let a = if value.len() == 8 {
686 u8::from_str_radix(&value[6..8], 16)
687 .map_err(|_| format!("unsupported color `{value}`"))?
688 } else {
689 u8::MAX
690 };
691
692 Ok(Self { r, g, b, a })
693 }
694}
695
696#[derive(Debug, Clone, Copy, PartialEq, Eq)]
698pub enum MetadataPolicy {
699 StripAll,
701 KeepAll,
703 PreserveExif,
705}
706
707pub fn resolve_metadata_flags(
750 strip: Option<bool>,
751 keep: Option<bool>,
752 preserve_exif: Option<bool>,
753) -> Result<(bool, bool), TransformError> {
754 let keep = keep.unwrap_or(false);
755 let preserve_exif = preserve_exif.unwrap_or(false);
756
757 if keep && preserve_exif {
758 return Err(TransformError::InvalidOptions(
759 "keepMetadata and preserveExif cannot both be true".to_string(),
760 ));
761 }
762
763 let strip_metadata = if keep || preserve_exif {
764 false
765 } else {
766 strip.unwrap_or(true)
767 };
768
769 Ok((strip_metadata, preserve_exif))
770}
771
772#[derive(Debug, Clone, PartialEq, Eq)]
774pub enum TransformError {
775 InvalidInput(String),
777 InvalidOptions(String),
779 UnsupportedInputMediaType(String),
781 UnsupportedOutputMediaType(MediaType),
783 DecodeFailed(String),
785 EncodeFailed(String),
787 CapabilityMissing(String),
789 LimitExceeded(String),
791}
792
793impl fmt::Display for TransformError {
794 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
795 match self {
796 Self::InvalidInput(reason) => write!(f, "invalid input: {reason}"),
797 Self::InvalidOptions(reason) => write!(f, "invalid transform options: {reason}"),
798 Self::UnsupportedInputMediaType(reason) => {
799 write!(f, "unsupported input media type: {reason}")
800 }
801 Self::UnsupportedOutputMediaType(media_type) => {
802 write!(f, "unsupported output media type: {media_type}")
803 }
804 Self::DecodeFailed(reason) => write!(f, "decode failed: {reason}"),
805 Self::EncodeFailed(reason) => write!(f, "encode failed: {reason}"),
806 Self::CapabilityMissing(reason) => write!(f, "missing capability: {reason}"),
807 Self::LimitExceeded(reason) => write!(f, "limit exceeded: {reason}"),
808 }
809 }
810}
811
812impl Error for TransformError {}
813
814#[derive(Debug, Clone, Copy, PartialEq, Eq)]
828pub enum MetadataKind {
829 Xmp,
831 Iptc,
833 Exif,
835 Icc,
837}
838
839impl fmt::Display for MetadataKind {
840 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
841 match self {
842 Self::Xmp => f.write_str("XMP"),
843 Self::Iptc => f.write_str("IPTC"),
844 Self::Exif => f.write_str("EXIF"),
845 Self::Icc => f.write_str("ICC profile"),
846 }
847 }
848}
849
850#[derive(Debug, Clone, PartialEq, Eq)]
866pub enum TransformWarning {
867 MetadataDropped(MetadataKind),
870}
871
872impl fmt::Display for TransformWarning {
873 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
874 match self {
875 Self::MetadataDropped(kind) => write!(
876 f,
877 "{kind} metadata was present in the input but could not be preserved by the output encoder"
878 ),
879 }
880 }
881}
882
883#[derive(Debug)]
888pub struct TransformResult {
889 pub artifact: Artifact,
891 pub warnings: Vec<TransformWarning>,
893}
894
895pub fn sniff_artifact(input: RawArtifact) -> Result<Artifact, TransformError> {
951 let (media_type, metadata) = detect_artifact(&input.bytes)?;
952
953 if let Some(declared_media_type) = input.declared_media_type
954 && declared_media_type != media_type
955 {
956 return Err(TransformError::InvalidInput(
957 "declared media type does not match detected media type".to_string(),
958 ));
959 }
960
961 Ok(Artifact::new(input.bytes, media_type, metadata))
962}
963
964fn validate_dimension(name: &str, value: Option<u32>) -> Result<(), TransformError> {
965 if matches!(value, Some(0)) {
966 return Err(TransformError::InvalidOptions(format!(
967 "{name} must be greater than zero"
968 )));
969 }
970
971 Ok(())
972}
973
974fn validate_quality(value: Option<u8>) -> Result<(), TransformError> {
975 if matches!(value, Some(0) | Some(101..=u8::MAX)) {
976 return Err(TransformError::InvalidOptions(
977 "quality must be between 1 and 100".to_string(),
978 ));
979 }
980
981 Ok(())
982}
983
984fn validate_blur(value: Option<f32>) -> Result<(), TransformError> {
985 if let Some(sigma) = value
986 && !(0.1..=100.0).contains(&sigma)
987 {
988 return Err(TransformError::InvalidOptions(
989 "blur sigma must be between 0.1 and 100.0".to_string(),
990 ));
991 }
992
993 Ok(())
994}
995
996fn validate_sharpen(value: Option<f32>) -> Result<(), TransformError> {
997 if let Some(sigma) = value
998 && !(0.1..=100.0).contains(&sigma)
999 {
1000 return Err(TransformError::InvalidOptions(
1001 "sharpen sigma must be between 0.1 and 100.0".to_string(),
1002 ));
1003 }
1004
1005 Ok(())
1006}
1007
1008fn validate_watermark(wm: &WatermarkInput) -> Result<(), TransformError> {
1009 if wm.opacity == 0 || wm.opacity > 100 {
1010 return Err(TransformError::InvalidOptions(
1011 "watermark opacity must be between 1 and 100".to_string(),
1012 ));
1013 }
1014
1015 if !wm.image.media_type.is_raster() {
1016 return Err(TransformError::InvalidOptions(
1017 "watermark image must be a raster format".to_string(),
1018 ));
1019 }
1020
1021 Ok(())
1022}
1023
1024fn normalize_metadata_policy(strip_metadata: bool, preserve_exif: bool) -> MetadataPolicy {
1025 if preserve_exif {
1026 MetadataPolicy::PreserveExif
1027 } else if strip_metadata {
1028 MetadataPolicy::StripAll
1029 } else {
1030 MetadataPolicy::KeepAll
1031 }
1032}
1033
1034fn detect_artifact(bytes: &[u8]) -> Result<(MediaType, ArtifactMetadata), TransformError> {
1035 if is_png(bytes) {
1036 return Ok((MediaType::Png, sniff_png(bytes)?));
1037 }
1038
1039 if is_jpeg(bytes) {
1040 return Ok((MediaType::Jpeg, sniff_jpeg(bytes)?));
1041 }
1042
1043 if is_webp(bytes) {
1044 return Ok((MediaType::Webp, sniff_webp(bytes)?));
1045 }
1046
1047 if is_avif(bytes) {
1048 return Ok((MediaType::Avif, sniff_avif(bytes)?));
1049 }
1050
1051 if is_bmp(bytes) {
1052 return Ok((MediaType::Bmp, sniff_bmp(bytes)?));
1053 }
1054
1055 if is_tiff(bytes) {
1056 return Ok((MediaType::Tiff, sniff_tiff(bytes)?));
1057 }
1058
1059 if is_svg(bytes) {
1062 return Ok((MediaType::Svg, sniff_svg(bytes)));
1063 }
1064
1065 Err(TransformError::UnsupportedInputMediaType(
1066 "unknown file signature".to_string(),
1067 ))
1068}
1069
1070fn is_png(bytes: &[u8]) -> bool {
1071 bytes.starts_with(b"\x89PNG\r\n\x1a\n")
1072}
1073
1074fn is_jpeg(bytes: &[u8]) -> bool {
1075 bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
1076}
1077
1078fn is_webp(bytes: &[u8]) -> bool {
1079 bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP"
1080}
1081
1082fn is_avif(bytes: &[u8]) -> bool {
1083 bytes.len() >= 16 && &bytes[4..8] == b"ftyp" && has_avif_brand(&bytes[8..])
1084}
1085
1086fn is_svg(bytes: &[u8]) -> bool {
1089 let text = match std::str::from_utf8(bytes) {
1090 Ok(s) => s,
1091 Err(_) => return false,
1092 };
1093
1094 let mut remaining = text.trim_start();
1095
1096 remaining = remaining.strip_prefix('\u{FEFF}').unwrap_or(remaining);
1098 remaining = remaining.trim_start();
1099
1100 if let Some(rest) = remaining.strip_prefix("<?xml") {
1102 if let Some(end) = rest.find("?>") {
1103 remaining = rest[end + 2..].trim_start();
1104 } else {
1105 return false;
1106 }
1107 }
1108
1109 if let Some(rest) = remaining.strip_prefix("<!DOCTYPE") {
1111 if let Some(end) = rest.find('>') {
1112 remaining = rest[end + 1..].trim_start();
1113 } else {
1114 return false;
1115 }
1116 }
1117
1118 while let Some(rest) = remaining.strip_prefix("<!--") {
1120 if let Some(end) = rest.find("-->") {
1121 remaining = rest[end + 3..].trim_start();
1122 } else {
1123 return false;
1124 }
1125 }
1126
1127 remaining.starts_with("<svg")
1128 && remaining
1129 .as_bytes()
1130 .get(4)
1131 .is_some_and(|&b| b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'>')
1132}
1133
1134fn sniff_svg(_bytes: &[u8]) -> ArtifactMetadata {
1138 ArtifactMetadata {
1139 width: None,
1140 height: None,
1141 frame_count: 1,
1142 duration: None,
1143 has_alpha: Some(true),
1144 }
1145}
1146
1147fn is_bmp(bytes: &[u8]) -> bool {
1149 bytes.len() >= 26 && bytes[0] == 0x42 && bytes[1] == 0x4D
1150}
1151
1152fn sniff_bmp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1159 if bytes.len() < 30 {
1160 return Err(TransformError::DecodeFailed(
1161 "bmp file is too short".to_string(),
1162 ));
1163 }
1164
1165 let width = u32::from_le_bytes([bytes[18], bytes[19], bytes[20], bytes[21]]);
1166 let raw_height = i32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]);
1167 let height = raw_height.unsigned_abs();
1168 let bits_per_pixel = u16::from_le_bytes([bytes[28], bytes[29]]);
1169
1170 let has_alpha = bits_per_pixel == 32;
1171
1172 Ok(ArtifactMetadata {
1173 width: Some(width),
1174 height: Some(height),
1175 frame_count: 1,
1176 duration: None,
1177 has_alpha: Some(has_alpha),
1178 })
1179}
1180
1181fn is_tiff(bytes: &[u8]) -> bool {
1185 bytes.len() >= 4
1186 && ((bytes[0] == b'I' && bytes[1] == b'I' && bytes[2] == 0x2A && bytes[3] == 0x00)
1187 || (bytes[0] == b'M' && bytes[1] == b'M' && bytes[2] == 0x00 && bytes[3] == 0x2A))
1188}
1189
1190fn sniff_tiff(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1192 let cursor = std::io::Cursor::new(bytes);
1193 let decoder = image::codecs::tiff::TiffDecoder::new(cursor)
1194 .map_err(|e| TransformError::DecodeFailed(format!("tiff decode: {e}")))?;
1195 let (width, height) = image::ImageDecoder::dimensions(&decoder);
1196 let color = image::ImageDecoder::color_type(&decoder);
1197 let has_alpha = matches!(
1198 color,
1199 image::ColorType::La8
1200 | image::ColorType::Rgba8
1201 | image::ColorType::La16
1202 | image::ColorType::Rgba16
1203 | image::ColorType::Rgba32F
1204 );
1205 Ok(ArtifactMetadata {
1206 width: Some(width),
1207 height: Some(height),
1208 frame_count: 1,
1209 duration: None,
1210 has_alpha: Some(has_alpha),
1211 })
1212}
1213
1214fn sniff_png(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1215 if bytes.len() < 29 {
1216 return Err(TransformError::DecodeFailed(
1217 "png file is too short".to_string(),
1218 ));
1219 }
1220
1221 if &bytes[12..16] != b"IHDR" {
1222 return Err(TransformError::DecodeFailed(
1223 "png file is missing an IHDR chunk".to_string(),
1224 ));
1225 }
1226
1227 let width = read_u32_be(&bytes[16..20])?;
1228 let height = read_u32_be(&bytes[20..24])?;
1229 let color_type = bytes[25];
1230 let has_alpha = match color_type {
1231 4 | 6 => Some(true),
1232 0 | 2 | 3 => Some(false),
1233 _ => None,
1234 };
1235
1236 Ok(ArtifactMetadata {
1237 width: Some(width),
1238 height: Some(height),
1239 frame_count: 1,
1240 duration: None,
1241 has_alpha,
1242 })
1243}
1244
1245fn sniff_jpeg(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1246 let mut offset = 2;
1247
1248 while offset + 1 < bytes.len() {
1249 if bytes[offset] != 0xFF {
1250 return Err(TransformError::DecodeFailed(
1251 "jpeg file has an invalid marker prefix".to_string(),
1252 ));
1253 }
1254
1255 while offset < bytes.len() && bytes[offset] == 0xFF {
1256 offset += 1;
1257 }
1258
1259 if offset >= bytes.len() {
1260 break;
1261 }
1262
1263 let marker = bytes[offset];
1264 offset += 1;
1265
1266 if marker == 0xD9 || marker == 0xDA {
1267 break;
1268 }
1269
1270 if (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
1271 continue;
1272 }
1273
1274 if offset + 2 > bytes.len() {
1275 return Err(TransformError::DecodeFailed(
1276 "jpeg segment is truncated".to_string(),
1277 ));
1278 }
1279
1280 let segment_length = read_u16_be(&bytes[offset..offset + 2])? as usize;
1281 if segment_length < 2 || offset + segment_length > bytes.len() {
1282 return Err(TransformError::DecodeFailed(
1283 "jpeg segment length is invalid".to_string(),
1284 ));
1285 }
1286
1287 if is_jpeg_sof_marker(marker) {
1288 if segment_length < 7 {
1289 return Err(TransformError::DecodeFailed(
1290 "jpeg SOF segment is too short".to_string(),
1291 ));
1292 }
1293
1294 let height = read_u16_be(&bytes[offset + 3..offset + 5])? as u32;
1295 let width = read_u16_be(&bytes[offset + 5..offset + 7])? as u32;
1296
1297 return Ok(ArtifactMetadata {
1298 width: Some(width),
1299 height: Some(height),
1300 frame_count: 1,
1301 duration: None,
1302 has_alpha: Some(false),
1303 });
1304 }
1305
1306 offset += segment_length;
1307 }
1308
1309 Err(TransformError::DecodeFailed(
1310 "jpeg file is missing a SOF segment".to_string(),
1311 ))
1312}
1313
1314fn sniff_webp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1315 let mut offset = 12;
1316
1317 while offset + 8 <= bytes.len() {
1318 let chunk_tag = &bytes[offset..offset + 4];
1319 let chunk_size = read_u32_le(&bytes[offset + 4..offset + 8])? as usize;
1320 let chunk_start = offset + 8;
1321 let chunk_end = chunk_start
1322 .checked_add(chunk_size)
1323 .ok_or_else(|| TransformError::DecodeFailed("webp chunk is too large".to_string()))?;
1324
1325 if chunk_end > bytes.len() {
1326 return Err(TransformError::DecodeFailed(
1327 "webp chunk exceeds file length".to_string(),
1328 ));
1329 }
1330
1331 let chunk_data = &bytes[chunk_start..chunk_end];
1332
1333 match chunk_tag {
1334 b"VP8X" => return sniff_webp_vp8x(chunk_data),
1335 b"VP8 " => return sniff_webp_vp8(chunk_data),
1336 b"VP8L" => return sniff_webp_vp8l(chunk_data),
1337 _ => {}
1338 }
1339
1340 offset = chunk_end + (chunk_size % 2);
1341 }
1342
1343 Err(TransformError::DecodeFailed(
1344 "webp file is missing an image chunk".to_string(),
1345 ))
1346}
1347
1348fn sniff_webp_vp8x(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1349 if bytes.len() < 10 {
1350 return Err(TransformError::DecodeFailed(
1351 "webp VP8X chunk is too short".to_string(),
1352 ));
1353 }
1354
1355 let flags = bytes[0];
1356 let width = read_u24_le(&bytes[4..7])? + 1;
1357 let height = read_u24_le(&bytes[7..10])? + 1;
1358 let has_alpha = Some(flags & 0b0001_0000 != 0);
1359
1360 Ok(ArtifactMetadata {
1361 width: Some(width),
1362 height: Some(height),
1363 frame_count: 1,
1364 duration: None,
1365 has_alpha,
1366 })
1367}
1368
1369fn sniff_webp_vp8(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1370 if bytes.len() < 10 {
1371 return Err(TransformError::DecodeFailed(
1372 "webp VP8 chunk is too short".to_string(),
1373 ));
1374 }
1375
1376 if bytes[3..6] != [0x9D, 0x01, 0x2A] {
1377 return Err(TransformError::DecodeFailed(
1378 "webp VP8 chunk has an invalid start code".to_string(),
1379 ));
1380 }
1381
1382 let width = (read_u16_le(&bytes[6..8])? & 0x3FFF) as u32;
1383 let height = (read_u16_le(&bytes[8..10])? & 0x3FFF) as u32;
1384
1385 Ok(ArtifactMetadata {
1386 width: Some(width),
1387 height: Some(height),
1388 frame_count: 1,
1389 duration: None,
1390 has_alpha: Some(false),
1391 })
1392}
1393
1394fn sniff_webp_vp8l(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1395 if bytes.len() < 5 {
1396 return Err(TransformError::DecodeFailed(
1397 "webp VP8L chunk is too short".to_string(),
1398 ));
1399 }
1400
1401 if bytes[0] != 0x2F {
1402 return Err(TransformError::DecodeFailed(
1403 "webp VP8L chunk has an invalid signature".to_string(),
1404 ));
1405 }
1406
1407 let bits = read_u32_le(&bytes[1..5])?;
1408 let width = (bits & 0x3FFF) + 1;
1409 let height = ((bits >> 14) & 0x3FFF) + 1;
1410
1411 Ok(ArtifactMetadata {
1412 width: Some(width),
1413 height: Some(height),
1414 frame_count: 1,
1415 duration: None,
1416 has_alpha: None,
1417 })
1418}
1419
1420fn sniff_avif(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1421 if bytes.len() < 16 {
1422 return Err(TransformError::DecodeFailed(
1423 "avif file is too short".to_string(),
1424 ));
1425 }
1426
1427 if !has_avif_brand(&bytes[8..]) {
1428 return Err(TransformError::DecodeFailed(
1429 "avif file is missing a compatible AVIF brand".to_string(),
1430 ));
1431 }
1432
1433 let inspection = inspect_avif_container(bytes)?;
1434
1435 Ok(ArtifactMetadata {
1436 width: inspection.dimensions.map(|(width, _)| width),
1437 height: inspection.dimensions.map(|(_, height)| height),
1438 frame_count: 1,
1439 duration: None,
1440 has_alpha: inspection.has_alpha(),
1441 })
1442}
1443
1444fn has_avif_brand(bytes: &[u8]) -> bool {
1445 if bytes.len() < 8 {
1446 return false;
1447 }
1448
1449 if is_avif_brand(&bytes[0..4]) {
1450 return true;
1451 }
1452
1453 let mut offset = 8;
1454 while offset + 4 <= bytes.len() {
1455 if is_avif_brand(&bytes[offset..offset + 4]) {
1456 return true;
1457 }
1458 offset += 4;
1459 }
1460
1461 false
1462}
1463
1464fn is_avif_brand(bytes: &[u8]) -> bool {
1465 matches!(bytes, b"avif" | b"avis")
1466}
1467
1468const AVIF_ALPHA_AUX_TYPE: &[u8] = b"urn:mpeg:mpegB:cicp:systems:auxiliary:alpha";
1469
1470#[derive(Debug, Default)]
1471struct AvifInspection {
1472 dimensions: Option<(u32, u32)>,
1473 saw_structured_meta: bool,
1474 found_alpha_item: bool,
1475}
1476
1477impl AvifInspection {
1478 fn has_alpha(&self) -> Option<bool> {
1479 if self.saw_structured_meta {
1480 Some(self.found_alpha_item)
1481 } else {
1482 None
1483 }
1484 }
1485}
1486
1487fn inspect_avif_container(bytes: &[u8]) -> Result<AvifInspection, TransformError> {
1488 let mut inspection = AvifInspection::default();
1489 inspect_avif_boxes(bytes, &mut inspection)?;
1490 Ok(inspection)
1491}
1492
1493fn inspect_avif_boxes(bytes: &[u8], inspection: &mut AvifInspection) -> Result<(), TransformError> {
1494 let mut offset = 0;
1495
1496 while offset + 8 <= bytes.len() {
1497 let (box_type, payload, next_offset) = parse_mp4_box(bytes, offset)?;
1498
1499 match box_type {
1500 b"meta" | b"iref" => {
1501 inspection.saw_structured_meta = true;
1502 if payload.len() < 4 {
1503 return Err(TransformError::DecodeFailed(format!(
1504 "{} box is too short",
1505 String::from_utf8_lossy(box_type)
1506 )));
1507 }
1508 inspect_avif_boxes(&payload[4..], inspection)?;
1509 }
1510 b"iprp" | b"ipco" => {
1511 inspection.saw_structured_meta = true;
1512 inspect_avif_boxes(payload, inspection)?;
1513 }
1514 b"ispe" => {
1515 inspection.saw_structured_meta = true;
1516 if inspection.dimensions.is_none() {
1517 inspection.dimensions = Some(parse_avif_ispe(payload)?);
1518 }
1519 }
1520 b"auxC" => {
1521 inspection.saw_structured_meta = true;
1522 if avif_auxc_declares_alpha(payload)? {
1523 inspection.found_alpha_item = true;
1524 }
1525 }
1526 b"auxl" => {
1527 inspection.saw_structured_meta = true;
1528 inspection.found_alpha_item = true;
1529 }
1530 _ => {}
1531 }
1532
1533 offset = next_offset;
1534 }
1535
1536 if offset != bytes.len() {
1537 return Err(TransformError::DecodeFailed(
1538 "avif box payload has trailing bytes".to_string(),
1539 ));
1540 }
1541
1542 Ok(())
1543}
1544
1545fn parse_mp4_box(bytes: &[u8], offset: usize) -> Result<(&[u8; 4], &[u8], usize), TransformError> {
1546 if offset + 8 > bytes.len() {
1547 return Err(TransformError::DecodeFailed(
1548 "mp4 box header is truncated".to_string(),
1549 ));
1550 }
1551
1552 let size = read_u32_be(&bytes[offset..offset + 4])?;
1553 let box_type = bytes[offset + 4..offset + 8]
1554 .try_into()
1555 .map_err(|_| TransformError::DecodeFailed("expected 4-byte box type".to_string()))?;
1556 let mut header_len = 8_usize;
1557 let end = match size {
1558 0 => bytes.len(),
1559 1 => {
1560 if offset + 16 > bytes.len() {
1561 return Err(TransformError::DecodeFailed(
1562 "extended mp4 box header is truncated".to_string(),
1563 ));
1564 }
1565 header_len = 16;
1566 let extended_size = read_u64_be(&bytes[offset + 8..offset + 16])?;
1567 usize::try_from(extended_size)
1568 .map_err(|_| TransformError::DecodeFailed("mp4 box is too large".to_string()))?
1569 }
1570 _ => size as usize,
1571 };
1572
1573 if end < header_len {
1574 return Err(TransformError::DecodeFailed(
1575 "mp4 box size is smaller than its header".to_string(),
1576 ));
1577 }
1578
1579 let box_end = offset
1580 .checked_add(end)
1581 .ok_or_else(|| TransformError::DecodeFailed("mp4 box is too large".to_string()))?;
1582 if box_end > bytes.len() {
1583 return Err(TransformError::DecodeFailed(
1584 "mp4 box exceeds file length".to_string(),
1585 ));
1586 }
1587
1588 Ok((box_type, &bytes[offset + header_len..box_end], box_end))
1589}
1590
1591fn parse_avif_ispe(bytes: &[u8]) -> Result<(u32, u32), TransformError> {
1592 if bytes.len() < 12 {
1593 return Err(TransformError::DecodeFailed(
1594 "avif ispe box is too short".to_string(),
1595 ));
1596 }
1597
1598 let width = read_u32_be(&bytes[4..8])?;
1599 let height = read_u32_be(&bytes[8..12])?;
1600 Ok((width, height))
1601}
1602
1603fn avif_auxc_declares_alpha(bytes: &[u8]) -> Result<bool, TransformError> {
1604 if bytes.len() < 5 {
1605 return Err(TransformError::DecodeFailed(
1606 "avif auxC box is too short".to_string(),
1607 ));
1608 }
1609
1610 let urn = &bytes[4..];
1611 Ok(urn
1612 .strip_suffix(&[0])
1613 .is_some_and(|urn| urn == AVIF_ALPHA_AUX_TYPE))
1614}
1615
1616fn is_jpeg_sof_marker(marker: u8) -> bool {
1617 matches!(
1618 marker,
1619 0xC0 | 0xC1 | 0xC2 | 0xC3 | 0xC5 | 0xC6 | 0xC7 | 0xC9 | 0xCA | 0xCB | 0xCD | 0xCE | 0xCF
1620 )
1621}
1622
1623fn read_u16_be(bytes: &[u8]) -> Result<u16, TransformError> {
1624 let array: [u8; 2] = bytes
1625 .try_into()
1626 .map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
1627 Ok(u16::from_be_bytes(array))
1628}
1629
1630fn read_u16_le(bytes: &[u8]) -> Result<u16, TransformError> {
1631 let array: [u8; 2] = bytes
1632 .try_into()
1633 .map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
1634 Ok(u16::from_le_bytes(array))
1635}
1636
1637fn read_u24_le(bytes: &[u8]) -> Result<u32, TransformError> {
1638 if bytes.len() != 3 {
1639 return Err(TransformError::DecodeFailed("expected 3 bytes".to_string()));
1640 }
1641
1642 Ok(u32::from(bytes[0]) | (u32::from(bytes[1]) << 8) | (u32::from(bytes[2]) << 16))
1643}
1644
1645fn read_u32_be(bytes: &[u8]) -> Result<u32, TransformError> {
1646 let array: [u8; 4] = bytes
1647 .try_into()
1648 .map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
1649 Ok(u32::from_be_bytes(array))
1650}
1651
1652fn read_u32_le(bytes: &[u8]) -> Result<u32, TransformError> {
1653 let array: [u8; 4] = bytes
1654 .try_into()
1655 .map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
1656 Ok(u32::from_le_bytes(array))
1657}
1658
1659fn read_u64_be(bytes: &[u8]) -> Result<u64, TransformError> {
1660 let array: [u8; 8] = bytes
1661 .try_into()
1662 .map_err(|_| TransformError::DecodeFailed("expected 8 bytes".to_string()))?;
1663 Ok(u64::from_be_bytes(array))
1664}
1665
1666#[cfg(test)]
1667mod tests {
1668 use super::{
1669 Artifact, ArtifactMetadata, Fit, MediaType, MetadataPolicy, Position, RawArtifact, Rgba8,
1670 Rotation, TransformError, TransformOptions, TransformRequest, sniff_artifact,
1671 };
1672 use image::codecs::avif::AvifEncoder;
1673 use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
1674
1675 fn jpeg_artifact() -> Artifact {
1676 Artifact::new(vec![1, 2, 3], MediaType::Jpeg, ArtifactMetadata::default())
1677 }
1678
1679 fn png_bytes(width: u32, height: u32, color_type: u8) -> Vec<u8> {
1680 let mut bytes = Vec::new();
1681 bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
1682 bytes.extend_from_slice(&13_u32.to_be_bytes());
1683 bytes.extend_from_slice(b"IHDR");
1684 bytes.extend_from_slice(&width.to_be_bytes());
1685 bytes.extend_from_slice(&height.to_be_bytes());
1686 bytes.push(8);
1687 bytes.push(color_type);
1688 bytes.push(0);
1689 bytes.push(0);
1690 bytes.push(0);
1691 bytes.extend_from_slice(&0_u32.to_be_bytes());
1692 bytes
1693 }
1694
1695 fn jpeg_bytes(width: u16, height: u16) -> Vec<u8> {
1696 let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
1697 bytes.extend_from_slice(&[0; 14]);
1698 bytes.extend_from_slice(&[
1699 0xFF,
1700 0xC0,
1701 0x00,
1702 0x11,
1703 0x08,
1704 (height >> 8) as u8,
1705 height as u8,
1706 (width >> 8) as u8,
1707 width as u8,
1708 0x03,
1709 0x01,
1710 0x11,
1711 0x00,
1712 0x02,
1713 0x11,
1714 0x00,
1715 0x03,
1716 0x11,
1717 0x00,
1718 ]);
1719 bytes.extend_from_slice(&[0xFF, 0xD9]);
1720 bytes
1721 }
1722
1723 fn webp_vp8x_bytes(width: u32, height: u32, flags: u8) -> Vec<u8> {
1724 let width_minus_one = width - 1;
1725 let height_minus_one = height - 1;
1726 let mut bytes = Vec::new();
1727 bytes.extend_from_slice(b"RIFF");
1728 bytes.extend_from_slice(&30_u32.to_le_bytes());
1729 bytes.extend_from_slice(b"WEBP");
1730 bytes.extend_from_slice(b"VP8X");
1731 bytes.extend_from_slice(&10_u32.to_le_bytes());
1732 bytes.push(flags);
1733 bytes.extend_from_slice(&[0, 0, 0]);
1734 bytes.extend_from_slice(&[
1735 (width_minus_one & 0xFF) as u8,
1736 ((width_minus_one >> 8) & 0xFF) as u8,
1737 ((width_minus_one >> 16) & 0xFF) as u8,
1738 ]);
1739 bytes.extend_from_slice(&[
1740 (height_minus_one & 0xFF) as u8,
1741 ((height_minus_one >> 8) & 0xFF) as u8,
1742 ((height_minus_one >> 16) & 0xFF) as u8,
1743 ]);
1744 bytes
1745 }
1746
1747 fn webp_vp8l_bytes(width: u32, height: u32) -> Vec<u8> {
1748 let packed = (width - 1) | ((height - 1) << 14);
1749 let mut bytes = Vec::new();
1750 bytes.extend_from_slice(b"RIFF");
1751 bytes.extend_from_slice(&17_u32.to_le_bytes());
1752 bytes.extend_from_slice(b"WEBP");
1753 bytes.extend_from_slice(b"VP8L");
1754 bytes.extend_from_slice(&5_u32.to_le_bytes());
1755 bytes.push(0x2F);
1756 bytes.extend_from_slice(&packed.to_le_bytes());
1757 bytes.push(0);
1758 bytes
1759 }
1760
1761 fn avif_bytes() -> Vec<u8> {
1762 let mut bytes = Vec::new();
1763 bytes.extend_from_slice(&24_u32.to_be_bytes());
1764 bytes.extend_from_slice(b"ftyp");
1765 bytes.extend_from_slice(b"avif");
1766 bytes.extend_from_slice(&0_u32.to_be_bytes());
1767 bytes.extend_from_slice(b"mif1");
1768 bytes.extend_from_slice(b"avif");
1769 bytes
1770 }
1771
1772 fn encoded_avif_bytes(width: u32, height: u32, fill: Rgba<u8>) -> Vec<u8> {
1773 let image = RgbaImage::from_pixel(width, height, fill);
1774 let mut bytes = Vec::new();
1775 AvifEncoder::new(&mut bytes)
1776 .write_image(&image, width, height, ColorType::Rgba8.into())
1777 .expect("encode avif");
1778 bytes
1779 }
1780
1781 #[test]
1782 fn default_transform_options_match_documented_defaults() {
1783 let options = TransformOptions::default();
1784
1785 assert_eq!(options.width, None);
1786 assert_eq!(options.height, None);
1787 assert_eq!(options.fit, None);
1788 assert_eq!(options.position, None);
1789 assert_eq!(options.format, None);
1790 assert_eq!(options.quality, None);
1791 assert_eq!(options.rotate, Rotation::Deg0);
1792 assert!(options.auto_orient);
1793 assert!(options.strip_metadata);
1794 assert!(!options.preserve_exif);
1795 }
1796
1797 #[test]
1798 fn media_type_helpers_report_expected_values() {
1799 assert_eq!(MediaType::Jpeg.as_name(), "jpeg");
1800 assert_eq!(MediaType::Jpeg.as_mime(), "image/jpeg");
1801 assert!(MediaType::Webp.is_lossy());
1802 assert!(!MediaType::Png.is_lossy());
1803 }
1804
1805 #[test]
1806 fn media_type_parsing_accepts_documented_names() {
1807 assert_eq!("jpeg".parse::<MediaType>(), Ok(MediaType::Jpeg));
1808 assert_eq!("jpg".parse::<MediaType>(), Ok(MediaType::Jpeg));
1809 assert_eq!("png".parse::<MediaType>(), Ok(MediaType::Png));
1810 assert!("gif".parse::<MediaType>().is_err());
1811 }
1812
1813 #[test]
1814 fn fit_position_rotation_and_color_parsing_work() {
1815 assert_eq!("cover".parse::<Fit>(), Ok(Fit::Cover));
1816 assert_eq!(
1817 "bottom-right".parse::<Position>(),
1818 Ok(Position::BottomRight)
1819 );
1820 assert_eq!("270".parse::<Rotation>(), Ok(Rotation::Deg270));
1821 assert_eq!(
1822 Rgba8::from_hex("AABBCCDD"),
1823 Ok(Rgba8 {
1824 r: 0xAA,
1825 g: 0xBB,
1826 b: 0xCC,
1827 a: 0xDD
1828 })
1829 );
1830 assert!(Rgba8::from_hex("AABB").is_err());
1831 }
1832
1833 #[test]
1834 fn normalize_defaults_fit_and_position_for_bounded_resize() {
1835 let normalized = TransformOptions {
1836 width: Some(1200),
1837 height: Some(630),
1838 ..TransformOptions::default()
1839 }
1840 .normalize(MediaType::Jpeg)
1841 .expect("normalize bounded resize");
1842
1843 assert_eq!(normalized.fit, Some(Fit::Contain));
1844 assert_eq!(normalized.position, Position::Center);
1845 assert_eq!(normalized.format, MediaType::Jpeg);
1846 assert_eq!(normalized.metadata_policy, MetadataPolicy::StripAll);
1847 }
1848
1849 #[test]
1850 fn normalize_uses_requested_fit_and_output_format() {
1851 let normalized = TransformOptions {
1852 width: Some(320),
1853 height: Some(320),
1854 fit: Some(Fit::Cover),
1855 position: Some(Position::BottomRight),
1856 format: Some(MediaType::Webp),
1857 quality: Some(70),
1858 strip_metadata: false,
1859 preserve_exif: true,
1860 ..TransformOptions::default()
1861 }
1862 .normalize(MediaType::Jpeg)
1863 .expect("normalize explicit values");
1864
1865 assert_eq!(normalized.fit, Some(Fit::Cover));
1866 assert_eq!(normalized.position, Position::BottomRight);
1867 assert_eq!(normalized.format, MediaType::Webp);
1868 assert_eq!(normalized.quality, Some(70));
1869 assert_eq!(normalized.metadata_policy, MetadataPolicy::PreserveExif);
1870 }
1871
1872 #[test]
1873 fn normalize_can_keep_all_metadata() {
1874 let normalized = TransformOptions {
1875 strip_metadata: false,
1876 ..TransformOptions::default()
1877 }
1878 .normalize(MediaType::Jpeg)
1879 .expect("normalize keep metadata");
1880
1881 assert_eq!(normalized.metadata_policy, MetadataPolicy::KeepAll);
1882 }
1883
1884 #[test]
1885 fn normalize_keeps_fit_none_when_resize_is_not_bounded() {
1886 let normalized = TransformOptions {
1887 width: Some(500),
1888 ..TransformOptions::default()
1889 }
1890 .normalize(MediaType::Jpeg)
1891 .expect("normalize unbounded resize");
1892
1893 assert_eq!(normalized.fit, None);
1894 assert_eq!(normalized.position, Position::Center);
1895 }
1896
1897 #[test]
1898 fn normalize_rejects_zero_dimensions() {
1899 let err = TransformOptions {
1900 width: Some(0),
1901 ..TransformOptions::default()
1902 }
1903 .normalize(MediaType::Jpeg)
1904 .expect_err("zero width should fail");
1905
1906 assert_eq!(
1907 err,
1908 TransformError::InvalidOptions("width must be greater than zero".to_string())
1909 );
1910 }
1911
1912 #[test]
1913 fn normalize_rejects_fit_without_both_dimensions() {
1914 let err = TransformOptions {
1915 width: Some(300),
1916 fit: Some(Fit::Contain),
1917 ..TransformOptions::default()
1918 }
1919 .normalize(MediaType::Jpeg)
1920 .expect_err("fit without bounded resize should fail");
1921
1922 assert_eq!(
1923 err,
1924 TransformError::InvalidOptions("fit requires both width and height".to_string())
1925 );
1926 }
1927
1928 #[test]
1929 fn normalize_rejects_position_without_both_dimensions() {
1930 let err = TransformOptions {
1931 height: Some(300),
1932 position: Some(Position::Top),
1933 ..TransformOptions::default()
1934 }
1935 .normalize(MediaType::Jpeg)
1936 .expect_err("position without bounded resize should fail");
1937
1938 assert_eq!(
1939 err,
1940 TransformError::InvalidOptions("position requires both width and height".to_string())
1941 );
1942 }
1943
1944 #[test]
1945 fn normalize_rejects_quality_for_lossless_output() {
1946 let err = TransformOptions {
1947 format: Some(MediaType::Png),
1948 quality: Some(80),
1949 ..TransformOptions::default()
1950 }
1951 .normalize(MediaType::Jpeg)
1952 .expect_err("quality for png should fail");
1953
1954 assert_eq!(
1955 err,
1956 TransformError::InvalidOptions("quality requires a lossy output format".to_string())
1957 );
1958 }
1959
1960 #[test]
1961 fn normalize_rejects_zero_quality() {
1962 let err = TransformOptions {
1963 quality: Some(0),
1964 ..TransformOptions::default()
1965 }
1966 .normalize(MediaType::Jpeg)
1967 .expect_err("zero quality should fail");
1968
1969 assert_eq!(
1970 err,
1971 TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
1972 );
1973 }
1974
1975 #[test]
1976 fn normalize_rejects_quality_above_one_hundred() {
1977 let err = TransformOptions {
1978 quality: Some(101),
1979 ..TransformOptions::default()
1980 }
1981 .normalize(MediaType::Jpeg)
1982 .expect_err("quality above one hundred should fail");
1983
1984 assert_eq!(
1985 err,
1986 TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
1987 );
1988 }
1989
1990 #[test]
1991 fn normalize_rejects_preserve_exif_when_metadata_is_stripped() {
1992 let err = TransformOptions {
1993 preserve_exif: true,
1994 ..TransformOptions::default()
1995 }
1996 .normalize(MediaType::Jpeg)
1997 .expect_err("preserve_exif should require metadata retention");
1998
1999 assert_eq!(
2000 err,
2001 TransformError::InvalidOptions(
2002 "preserve_exif requires strip_metadata to be false".to_string()
2003 )
2004 );
2005 }
2006
2007 #[test]
2008 fn transform_request_normalize_uses_input_media_type_as_default_output() {
2009 let request = TransformRequest::new(jpeg_artifact(), TransformOptions::default());
2010 let normalized = request.normalize().expect("normalize request");
2011
2012 assert_eq!(normalized.input.media_type, MediaType::Jpeg);
2013 assert_eq!(normalized.options.format, MediaType::Jpeg);
2014 assert_eq!(normalized.options.metadata_policy, MetadataPolicy::StripAll);
2015 }
2016
2017 #[test]
2018 fn sniff_artifact_detects_png_dimensions_and_alpha() {
2019 let artifact =
2020 sniff_artifact(RawArtifact::new(png_bytes(64, 32, 6), None)).expect("sniff png");
2021
2022 assert_eq!(artifact.media_type, MediaType::Png);
2023 assert_eq!(artifact.metadata.width, Some(64));
2024 assert_eq!(artifact.metadata.height, Some(32));
2025 assert_eq!(artifact.metadata.has_alpha, Some(true));
2026 }
2027
2028 #[test]
2029 fn sniff_artifact_detects_jpeg_dimensions() {
2030 let artifact =
2031 sniff_artifact(RawArtifact::new(jpeg_bytes(320, 240), None)).expect("sniff jpeg");
2032
2033 assert_eq!(artifact.media_type, MediaType::Jpeg);
2034 assert_eq!(artifact.metadata.width, Some(320));
2035 assert_eq!(artifact.metadata.height, Some(240));
2036 assert_eq!(artifact.metadata.has_alpha, Some(false));
2037 }
2038
2039 #[test]
2040 fn sniff_artifact_detects_webp_vp8x_dimensions() {
2041 let artifact = sniff_artifact(RawArtifact::new(
2042 webp_vp8x_bytes(800, 600, 0b0001_0000),
2043 None,
2044 ))
2045 .expect("sniff webp vp8x");
2046
2047 assert_eq!(artifact.media_type, MediaType::Webp);
2048 assert_eq!(artifact.metadata.width, Some(800));
2049 assert_eq!(artifact.metadata.height, Some(600));
2050 assert_eq!(artifact.metadata.has_alpha, Some(true));
2051 }
2052
2053 #[test]
2054 fn sniff_artifact_detects_webp_vp8l_dimensions() {
2055 let artifact = sniff_artifact(RawArtifact::new(webp_vp8l_bytes(123, 77), None))
2056 .expect("sniff webp vp8l");
2057
2058 assert_eq!(artifact.media_type, MediaType::Webp);
2059 assert_eq!(artifact.metadata.width, Some(123));
2060 assert_eq!(artifact.metadata.height, Some(77));
2061 }
2062
2063 #[test]
2064 fn sniff_artifact_detects_avif_brand() {
2065 let artifact = sniff_artifact(RawArtifact::new(avif_bytes(), None)).expect("sniff avif");
2066
2067 assert_eq!(artifact.media_type, MediaType::Avif);
2068 assert_eq!(artifact.metadata, ArtifactMetadata::default());
2069 }
2070
2071 #[test]
2072 fn sniff_artifact_detects_avif_dimensions_and_alpha() {
2073 let artifact = sniff_artifact(RawArtifact::new(
2074 encoded_avif_bytes(7, 5, Rgba([10, 20, 30, 0])),
2075 None,
2076 ))
2077 .expect("sniff avif with alpha");
2078
2079 assert_eq!(artifact.media_type, MediaType::Avif);
2080 assert_eq!(artifact.metadata.width, Some(7));
2081 assert_eq!(artifact.metadata.height, Some(5));
2082 assert_eq!(artifact.metadata.has_alpha, Some(true));
2083 }
2084
2085 #[test]
2086 fn sniff_artifact_detects_opaque_avif_without_alpha_item() {
2087 let artifact = sniff_artifact(RawArtifact::new(
2088 encoded_avif_bytes(9, 4, Rgba([10, 20, 30, 255])),
2089 None,
2090 ))
2091 .expect("sniff opaque avif");
2092
2093 assert_eq!(artifact.media_type, MediaType::Avif);
2094 assert_eq!(artifact.metadata.width, Some(9));
2095 assert_eq!(artifact.metadata.height, Some(4));
2096 assert_eq!(artifact.metadata.has_alpha, Some(false));
2097 }
2098
2099 #[test]
2100 fn sniff_artifact_rejects_declared_media_type_mismatch() {
2101 let err = sniff_artifact(RawArtifact::new(png_bytes(8, 8, 2), Some(MediaType::Jpeg)))
2102 .expect_err("declared mismatch should fail");
2103
2104 assert_eq!(
2105 err,
2106 TransformError::InvalidInput(
2107 "declared media type does not match detected media type".to_string()
2108 )
2109 );
2110 }
2111
2112 #[test]
2113 fn sniff_artifact_rejects_unknown_signatures() {
2114 let err =
2115 sniff_artifact(RawArtifact::new(vec![1, 2, 3, 4], None)).expect_err("unknown bytes");
2116
2117 assert_eq!(
2118 err,
2119 TransformError::UnsupportedInputMediaType("unknown file signature".to_string())
2120 );
2121 }
2122
2123 #[test]
2124 fn sniff_artifact_rejects_invalid_png_structure() {
2125 let err = sniff_artifact(RawArtifact::new(b"\x89PNG\r\n\x1a\nbroken".to_vec(), None))
2126 .expect_err("broken png should fail");
2127
2128 assert_eq!(
2129 err,
2130 TransformError::DecodeFailed("png file is too short".to_string())
2131 );
2132 }
2133
2134 #[test]
2135 fn sniff_artifact_detects_bmp_dimensions() {
2136 let mut bmp = Vec::new();
2139 bmp.extend_from_slice(b"BM");
2141 bmp.extend_from_slice(&0u32.to_le_bytes());
2143 bmp.extend_from_slice(&0u32.to_le_bytes());
2145 bmp.extend_from_slice(&54u32.to_le_bytes());
2147 bmp.extend_from_slice(&40u32.to_le_bytes());
2149 bmp.extend_from_slice(&8u32.to_le_bytes());
2151 bmp.extend_from_slice(&6i32.to_le_bytes());
2153 bmp.extend_from_slice(&1u16.to_le_bytes());
2155 bmp.extend_from_slice(&24u16.to_le_bytes());
2157 bmp.resize(54, 0);
2159
2160 let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
2161 assert_eq!(artifact.media_type, MediaType::Bmp);
2162 assert_eq!(artifact.metadata.width, Some(8));
2163 assert_eq!(artifact.metadata.height, Some(6));
2164 assert_eq!(artifact.metadata.has_alpha, Some(false));
2165 }
2166
2167 #[test]
2168 fn sniff_artifact_detects_bmp_32bit_alpha() {
2169 let mut bmp = Vec::new();
2170 bmp.extend_from_slice(b"BM");
2171 bmp.extend_from_slice(&0u32.to_le_bytes());
2172 bmp.extend_from_slice(&0u32.to_le_bytes());
2173 bmp.extend_from_slice(&54u32.to_le_bytes());
2174 bmp.extend_from_slice(&40u32.to_le_bytes());
2175 bmp.extend_from_slice(&4u32.to_le_bytes());
2177 bmp.extend_from_slice(&4i32.to_le_bytes());
2179 bmp.extend_from_slice(&1u16.to_le_bytes());
2181 bmp.extend_from_slice(&32u16.to_le_bytes());
2183 bmp.resize(54, 0);
2184
2185 let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
2186 assert_eq!(artifact.media_type, MediaType::Bmp);
2187 assert_eq!(artifact.metadata.has_alpha, Some(true));
2188 }
2189
2190 #[test]
2191 fn sniff_artifact_rejects_too_short_bmp() {
2192 let mut data = b"BM".to_vec();
2194 data.resize(27, 0);
2195 let err =
2196 sniff_artifact(RawArtifact::new(data, None)).expect_err("too-short BMP should fail");
2197
2198 assert_eq!(
2199 err,
2200 TransformError::DecodeFailed("bmp file is too short".to_string())
2201 );
2202 }
2203
2204 #[test]
2205 fn normalize_rejects_blur_sigma_below_minimum() {
2206 let err = TransformOptions {
2207 blur: Some(0.0),
2208 ..TransformOptions::default()
2209 }
2210 .normalize(MediaType::Jpeg)
2211 .expect_err("blur sigma 0.0 should be rejected");
2212
2213 assert_eq!(
2214 err,
2215 TransformError::InvalidOptions("blur sigma must be between 0.1 and 100.0".to_string())
2216 );
2217 }
2218
2219 #[test]
2220 fn normalize_rejects_blur_sigma_above_maximum() {
2221 let err = TransformOptions {
2222 blur: Some(100.1),
2223 ..TransformOptions::default()
2224 }
2225 .normalize(MediaType::Jpeg)
2226 .expect_err("blur sigma 100.1 should be rejected");
2227
2228 assert_eq!(
2229 err,
2230 TransformError::InvalidOptions("blur sigma must be between 0.1 and 100.0".to_string())
2231 );
2232 }
2233
2234 #[test]
2235 fn normalize_accepts_blur_sigma_at_boundaries() {
2236 let opts_min = TransformOptions {
2237 blur: Some(0.1),
2238 ..TransformOptions::default()
2239 }
2240 .normalize(MediaType::Jpeg)
2241 .expect("blur sigma 0.1 should be accepted");
2242 assert_eq!(opts_min.blur, Some(0.1));
2243
2244 let opts_max = TransformOptions {
2245 blur: Some(100.0),
2246 ..TransformOptions::default()
2247 }
2248 .normalize(MediaType::Jpeg)
2249 .expect("blur sigma 100.0 should be accepted");
2250 assert_eq!(opts_max.blur, Some(100.0));
2251 }
2252
2253 #[test]
2254 fn normalize_rejects_sharpen_sigma_below_minimum() {
2255 let err = TransformOptions {
2256 sharpen: Some(0.0),
2257 ..TransformOptions::default()
2258 }
2259 .normalize(MediaType::Jpeg)
2260 .expect_err("sharpen sigma 0.0 should be rejected");
2261
2262 assert_eq!(
2263 err,
2264 TransformError::InvalidOptions(
2265 "sharpen sigma must be between 0.1 and 100.0".to_string()
2266 )
2267 );
2268 }
2269
2270 #[test]
2271 fn normalize_rejects_sharpen_sigma_above_maximum() {
2272 let err = TransformOptions {
2273 sharpen: Some(100.1),
2274 ..TransformOptions::default()
2275 }
2276 .normalize(MediaType::Jpeg)
2277 .expect_err("sharpen sigma 100.1 should be rejected");
2278
2279 assert_eq!(
2280 err,
2281 TransformError::InvalidOptions(
2282 "sharpen sigma must be between 0.1 and 100.0".to_string()
2283 )
2284 );
2285 }
2286
2287 #[test]
2288 fn normalize_accepts_sharpen_sigma_at_boundaries() {
2289 let opts_min = TransformOptions {
2290 sharpen: Some(0.1),
2291 ..TransformOptions::default()
2292 }
2293 .normalize(MediaType::Jpeg)
2294 .expect("sharpen sigma 0.1 should be accepted");
2295 assert_eq!(opts_min.sharpen, Some(0.1));
2296
2297 let opts_max = TransformOptions {
2298 sharpen: Some(100.0),
2299 ..TransformOptions::default()
2300 }
2301 .normalize(MediaType::Jpeg)
2302 .expect("sharpen sigma 100.0 should be accepted");
2303 assert_eq!(opts_max.sharpen, Some(100.0));
2304 }
2305
2306 #[test]
2307 fn validate_watermark_rejects_zero_opacity() {
2308 let wm = super::WatermarkInput {
2309 image: jpeg_artifact(),
2310 position: Position::BottomRight,
2311 opacity: 0,
2312 margin: 10,
2313 };
2314 let err = super::validate_watermark(&wm).expect_err("opacity 0 should be rejected");
2315 assert_eq!(
2316 err,
2317 TransformError::InvalidOptions(
2318 "watermark opacity must be between 1 and 100".to_string()
2319 )
2320 );
2321 }
2322
2323 #[test]
2324 fn validate_watermark_rejects_opacity_above_100() {
2325 let wm = super::WatermarkInput {
2326 image: jpeg_artifact(),
2327 position: Position::BottomRight,
2328 opacity: 101,
2329 margin: 10,
2330 };
2331 let err = super::validate_watermark(&wm).expect_err("opacity 101 should be rejected");
2332 assert_eq!(
2333 err,
2334 TransformError::InvalidOptions(
2335 "watermark opacity must be between 1 and 100".to_string()
2336 )
2337 );
2338 }
2339
2340 #[test]
2341 fn validate_watermark_rejects_svg_image() {
2342 let wm = super::WatermarkInput {
2343 image: Artifact::new(vec![1], MediaType::Svg, ArtifactMetadata::default()),
2344 position: Position::BottomRight,
2345 opacity: 50,
2346 margin: 10,
2347 };
2348 let err = super::validate_watermark(&wm).expect_err("SVG watermark should be rejected");
2349 assert_eq!(
2350 err,
2351 TransformError::InvalidOptions("watermark image must be a raster format".to_string())
2352 );
2353 }
2354
2355 #[test]
2356 fn validate_watermark_accepts_valid_input() {
2357 let wm = super::WatermarkInput {
2358 image: jpeg_artifact(),
2359 position: Position::BottomRight,
2360 opacity: 50,
2361 margin: 10,
2362 };
2363 super::validate_watermark(&wm).expect("valid watermark should be accepted");
2364 }
2365
2366 #[test]
2367 fn crop_region_from_str_valid() {
2368 use super::CropRegion;
2369 let crop: CropRegion = "10,20,100,200".parse().expect("valid crop");
2370 assert_eq!(crop.x, 10);
2371 assert_eq!(crop.y, 20);
2372 assert_eq!(crop.width, 100);
2373 assert_eq!(crop.height, 200);
2374 }
2375
2376 #[test]
2377 fn crop_region_from_str_zero_width() {
2378 use super::CropRegion;
2379 let err = "10,20,0,200"
2380 .parse::<CropRegion>()
2381 .expect_err("zero width should fail");
2382 assert!(err.contains("greater than zero"), "unexpected error: {err}");
2383 }
2384
2385 #[test]
2386 fn crop_region_from_str_wrong_parts() {
2387 use super::CropRegion;
2388 let err = "10,20,100"
2389 .parse::<CropRegion>()
2390 .expect_err("three parts should fail");
2391 assert!(
2392 err.contains("four comma-separated"),
2393 "unexpected error: {err}"
2394 );
2395 }
2396
2397 #[test]
2398 fn crop_region_display() {
2399 use super::CropRegion;
2400 let crop = CropRegion {
2401 x: 1,
2402 y: 2,
2403 width: 3,
2404 height: 4,
2405 };
2406 assert_eq!(crop.to_string(), "1,2,3,4");
2407 }
2408
2409 #[test]
2410 fn normalize_rejects_zero_dimension_crop() {
2411 use super::{CropRegion, MediaType, TransformOptions};
2412 let opts = TransformOptions {
2413 crop: Some(CropRegion {
2414 x: 0,
2415 y: 0,
2416 width: 0,
2417 height: 100,
2418 }),
2419 ..TransformOptions::default()
2420 };
2421 let err = opts
2422 .normalize(MediaType::Jpeg)
2423 .expect_err("zero-width crop should fail");
2424 assert!(
2425 matches!(err, super::TransformError::InvalidOptions(_)),
2426 "unexpected error: {err:?}"
2427 );
2428 }
2429}