1use std::error::Error;
4use std::fmt;
5use std::str::FromStr;
6use std::time::Duration;
7
8#[cfg(feature = "avif")]
9pub(crate) use avif::avif_clean_aperture;
10pub(crate) use avif::avif_carries_metadata;
13#[cfg(feature = "avif")]
14pub(crate) use avif::{avif_metadata, avif_with_metadata};
15use avif::{avif_orientation, has_avif_brand, sniff_avif};
16
17mod avif;
21#[cfg(any(feature = "server", feature = "wasm"))]
22pub(crate) mod error_class;
23#[cfg(feature = "server")]
26pub(crate) mod remote_policy;
27
28pub const MAX_OUTPUT_PIXELS: u64 = 67_108_864;
37
38pub(crate) const MAX_DECODED_PIXELS: u64 = 100_000_000;
43
44pub(crate) const MAX_WATERMARK_PIXELS: u64 = 4_000_000;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63#[must_use]
64pub struct Dimensions {
65 pub width: u32,
66 pub height: u32,
67}
68
69impl Dimensions {
70 pub const fn new(width: u32, height: u32) -> Self {
72 Self { width, height }
73 }
74
75 #[must_use]
77 pub const fn pixel_count(self) -> u64 {
78 self.width as u64 * self.height as u64
79 }
80}
81
82impl fmt::Display for Dimensions {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 write!(f, "{}x{}", self.width, self.height)
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
102#[non_exhaustive]
103pub struct RawArtifact {
104 pub bytes: Vec<u8>,
106 pub declared_media_type: Option<MediaType>,
108}
109
110impl RawArtifact {
111 pub fn new(bytes: Vec<u8>, declared_media_type: Option<MediaType>) -> Self {
113 Self {
114 bytes,
115 declared_media_type,
116 }
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
136#[must_use]
137#[non_exhaustive]
138pub struct Artifact {
139 pub bytes: Vec<u8>,
141 pub media_type: MediaType,
143 pub metadata: ArtifactMetadata,
145}
146
147impl Artifact {
148 pub fn new(bytes: Vec<u8>, media_type: MediaType, metadata: ArtifactMetadata) -> Self {
150 Self {
151 bytes,
152 media_type,
153 metadata,
154 }
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
187#[non_exhaustive]
188pub struct ArtifactMetadata {
189 pub width: Option<u32>,
191 pub height: Option<u32>,
193 pub frame_count: u32,
195 pub duration: Option<Duration>,
197 pub has_alpha: Option<bool>,
199 pub orientation: Option<u16>,
207}
208
209impl ArtifactMetadata {
210 pub fn dimensions(&self) -> Option<Dimensions> {
212 match (self.width, self.height) {
213 (Some(w), Some(h)) => Some(Dimensions::new(w, h)),
214 _ => None,
215 }
216 }
217
218 pub fn oriented_dimensions(&self) -> Option<Dimensions> {
224 let dimensions = self.dimensions()?;
225 Some(if orientation_transposes(self.orientation) {
226 Dimensions::new(dimensions.height, dimensions.width)
227 } else {
228 dimensions
229 })
230 }
231}
232
233pub(crate) const fn orientation_transposes(orientation: Option<u16>) -> bool {
238 matches!(orientation, Some(5..=8))
239}
240
241impl Default for ArtifactMetadata {
242 fn default() -> Self {
243 Self {
244 width: None,
245 height: None,
246 frame_count: 1,
247 duration: None,
248 has_alpha: None,
249 orientation: None,
250 }
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273#[non_exhaustive]
274pub enum MediaType {
275 Jpeg,
277 Png,
279 Webp,
281 Avif,
283 Svg,
285 Bmp,
287 Tiff,
289 Gif,
295}
296
297impl MediaType {
298 #[must_use]
300 pub const fn as_name(self) -> &'static str {
301 match self {
302 Self::Jpeg => "jpeg",
303 Self::Png => "png",
304 Self::Webp => "webp",
305 Self::Avif => "avif",
306 Self::Svg => "svg",
307 Self::Bmp => "bmp",
308 Self::Tiff => "tiff",
309 Self::Gif => "gif",
310 }
311 }
312
313 #[must_use]
315 pub const fn as_mime(self) -> &'static str {
316 match self {
317 Self::Jpeg => "image/jpeg",
318 Self::Png => "image/png",
319 Self::Webp => "image/webp",
320 Self::Avif => "image/avif",
321 Self::Svg => "image/svg+xml",
322 Self::Bmp => "image/bmp",
323 Self::Tiff => "image/tiff",
324 Self::Gif => "image/gif",
325 }
326 }
327
328 #[must_use]
330 pub const fn is_lossy(self) -> bool {
331 matches!(self, Self::Jpeg | Self::Webp | Self::Avif)
332 }
333
334 #[must_use]
336 pub const fn supports_optimization(self) -> bool {
337 matches!(self, Self::Jpeg | Self::Png | Self::Webp | Self::Avif)
338 }
339
340 #[must_use]
342 pub const fn supports_lossy_optimization(self) -> bool {
343 matches!(self, Self::Jpeg | Self::Webp | Self::Avif)
344 }
345
346 pub(crate) const fn max_output_dimension(self) -> Option<u32> {
362 match self {
363 Self::Jpeg | Self::Avif => Some(65_535),
364 Self::Webp => Some(16_383),
365 Self::Png | Self::Bmp | Self::Tiff | Self::Gif | Self::Svg => None,
366 }
367 }
368
369 #[must_use]
374 pub const fn supports_icc_profile(self) -> bool {
375 matches!(self, Self::Jpeg | Self::Png | Self::Webp)
376 }
377
378 #[must_use]
380 pub const fn is_raster(self) -> bool {
381 !matches!(self, Self::Svg)
382 }
383
384 #[must_use]
392 pub const fn is_encodable(self) -> bool {
393 !matches!(self, Self::Gif)
394 }
395
396 pub(crate) fn unencodable_reason(self) -> Option<String> {
404 (!self.is_encodable()).then(|| {
405 format!(
406 "{} is an input-only format; choose an output format such as png, jpeg, webp, or avif",
407 self.as_name()
408 )
409 })
410 }
411
412 #[must_use]
431 pub const fn default_output(self) -> Self {
432 if self.is_encodable() { self } else { Self::Png }
433 }
434}
435
436impl fmt::Display for MediaType {
437 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
438 f.write_str(self.as_mime())
439 }
440}
441
442impl FromStr for MediaType {
443 type Err = String;
444
445 fn from_str(value: &str) -> Result<Self, Self::Err> {
446 match value {
447 "jpeg" | "jpg" => Ok(Self::Jpeg),
448 "png" => Ok(Self::Png),
449 "webp" => Ok(Self::Webp),
450 "avif" => Ok(Self::Avif),
451 "svg" => Ok(Self::Svg),
452 "bmp" => Ok(Self::Bmp),
453 "tiff" | "tif" => Ok(Self::Tiff),
454 "gif" => Ok(Self::Gif),
455 _ => Err(format!("unsupported media type `{value}`")),
456 }
457 }
458}
459
460pub(crate) const WATERMARK_DEFAULT_POSITION: Position = Position::BottomRight;
462pub(crate) const WATERMARK_DEFAULT_OPACITY: u8 = 50;
464pub(crate) const WATERMARK_DEFAULT_MARGIN: u32 = 10;
466
467#[derive(Debug, Clone, PartialEq, Eq)]
485#[non_exhaustive]
486pub struct WatermarkInput {
487 pub image: Artifact,
489 pub position: Position,
491 pub opacity: u8,
493 pub margin: u32,
495}
496
497impl WatermarkInput {
498 #[must_use]
507 pub fn new(image: Artifact) -> Self {
508 Self {
509 image,
510 position: WATERMARK_DEFAULT_POSITION,
511 opacity: WATERMARK_DEFAULT_OPACITY,
512 margin: WATERMARK_DEFAULT_MARGIN,
513 }
514 }
515}
516
517#[derive(Debug, Clone, PartialEq)]
529#[non_exhaustive]
530pub struct TransformRequest {
531 pub input: Artifact,
533 pub options: TransformOptions,
535 pub watermark: Option<WatermarkInput>,
537}
538
539impl TransformRequest {
540 pub fn new(input: Artifact, options: TransformOptions) -> Self {
542 Self {
543 input,
544 options,
545 watermark: None,
546 }
547 }
548
549 pub fn with_watermark(
551 input: Artifact,
552 options: TransformOptions,
553 watermark: WatermarkInput,
554 ) -> Self {
555 Self {
556 input,
557 options,
558 watermark: Some(watermark),
559 }
560 }
561
562 pub(crate) fn normalize(self) -> Result<NormalizedTransformRequest, TransformError> {
564 let options = self.options.normalize(self.input.media_type)?;
565
566 if let Some(ref wm) = self.watermark {
567 validate_watermark(wm)?;
568 }
569
570 Ok(NormalizedTransformRequest {
571 input: self.input,
572 options,
573 watermark: self.watermark,
574 })
575 }
576}
577
578#[derive(Debug, Clone, PartialEq)]
580#[non_exhaustive]
581pub(crate) struct NormalizedTransformRequest {
582 pub input: Artifact,
584 pub options: NormalizedTransformOptions,
586 pub watermark: Option<WatermarkInput>,
588}
589
590#[derive(Debug, Clone, Copy, PartialEq, Eq)]
613pub struct CropRegion {
614 pub x: u32,
616 pub y: u32,
618 pub width: u32,
620 pub height: u32,
622}
623
624impl FromStr for CropRegion {
625 type Err = String;
626
627 fn from_str(s: &str) -> Result<Self, Self::Err> {
628 let parts: Vec<&str> = s.split(',').collect();
629 if parts.len() != 4 {
630 return Err(format!(
631 "crop must be x,y,w,h (four comma-separated integers), got '{s}'"
632 ));
633 }
634 let x = parts[0]
635 .parse::<u32>()
636 .map_err(|_| format!("crop x must be a non-negative integer, got '{}'", parts[0]))?;
637 let y = parts[1]
638 .parse::<u32>()
639 .map_err(|_| format!("crop y must be a non-negative integer, got '{}'", parts[1]))?;
640 let width = parts[2].parse::<u32>().map_err(|_| {
641 format!(
642 "crop width must be a non-negative integer, got '{}'",
643 parts[2]
644 )
645 })?;
646 let height = parts[3].parse::<u32>().map_err(|_| {
647 format!(
648 "crop height must be a non-negative integer, got '{}'",
649 parts[3]
650 )
651 })?;
652 if width == 0 || height == 0 {
653 return Err("crop width and height must be greater than zero".to_string());
654 }
655 Ok(CropRegion {
656 x,
657 y,
658 width,
659 height,
660 })
661 }
662}
663
664impl fmt::Display for CropRegion {
665 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
666 write!(f, "{},{},{},{}", self.x, self.y, self.width, self.height)
667 }
668}
669
670#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
672#[non_exhaustive]
673pub enum OptimizeMode {
674 #[default]
676 None,
677 Auto,
679 Lossless,
681 Lossy,
683}
684
685impl OptimizeMode {
686 #[must_use]
688 pub const fn as_name(self) -> &'static str {
689 match self {
690 Self::None => "none",
691 Self::Auto => "auto",
692 Self::Lossless => "lossless",
693 Self::Lossy => "lossy",
694 }
695 }
696}
697
698impl fmt::Display for OptimizeMode {
699 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
700 f.write_str(self.as_name())
701 }
702}
703
704impl FromStr for OptimizeMode {
705 type Err = String;
706
707 fn from_str(value: &str) -> Result<Self, Self::Err> {
708 match value {
709 "none" => Ok(Self::None),
710 "auto" => Ok(Self::Auto),
711 "lossless" => Ok(Self::Lossless),
712 "lossy" => Ok(Self::Lossy),
713 _ => Err(format!("unsupported optimize mode `{value}`")),
714 }
715 }
716}
717
718#[derive(Debug, Clone, Copy, PartialEq, Eq)]
720#[non_exhaustive]
721pub enum QualityMetric {
722 Ssim,
724 Psnr,
726}
727
728impl QualityMetric {
729 #[must_use]
731 pub const fn as_name(self) -> &'static str {
732 match self {
733 Self::Ssim => "ssim",
734 Self::Psnr => "psnr",
735 }
736 }
737}
738
739impl fmt::Display for QualityMetric {
740 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
741 f.write_str(self.as_name())
742 }
743}
744
745impl FromStr for QualityMetric {
746 type Err = String;
747
748 fn from_str(value: &str) -> Result<Self, Self::Err> {
749 match value {
750 "ssim" => Ok(Self::Ssim),
751 "psnr" => Ok(Self::Psnr),
752 _ => Err(format!("unsupported target quality metric `{value}`")),
753 }
754 }
755}
756
757#[derive(Debug, Clone, Copy, PartialEq)]
769pub struct TargetQuality {
770 pub metric: QualityMetric,
772 pub value: f32,
774}
775
776impl fmt::Display for TargetQuality {
777 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
778 write!(f, "{}:{}", self.metric.as_name(), self.value)
779 }
780}
781
782impl FromStr for TargetQuality {
783 type Err = String;
784
785 fn from_str(value: &str) -> Result<Self, Self::Err> {
786 let (metric, raw_value) = value.split_once(':').ok_or_else(|| {
787 "targetQuality must be <metric>:<value>, for example ssim:0.98".to_string()
788 })?;
789 let metric = QualityMetric::from_str(metric)?;
793 let value = raw_value
794 .parse::<f32>()
795 .map_err(|_| format!("target quality value must be a number, got `{raw_value}`"))?;
796
797 Ok(Self { metric, value })
798 }
799}
800
801pub(crate) fn default_lossy_target_quality(media_type: MediaType) -> Option<TargetQuality> {
802 let value = match media_type {
803 MediaType::Jpeg | MediaType::Webp => 0.985,
804 MediaType::Avif => 0.99,
805 _ => return None,
806 };
807
808 Some(TargetQuality {
809 metric: QualityMetric::Ssim,
810 value,
811 })
812}
813
814#[derive(Debug, Clone, PartialEq)]
840#[non_exhaustive]
841pub struct TransformOptions {
842 pub width: Option<u32>,
844 pub height: Option<u32>,
846 pub fit: Option<Fit>,
848 pub position: Option<Position>,
850 pub format: Option<MediaType>,
852 pub quality: Option<u8>,
854 pub optimize: OptimizeMode,
856 pub target_quality: Option<TargetQuality>,
858 pub background: Option<Rgba8>,
860 pub rotate: Rotation,
862 pub auto_orient: bool,
864 pub strip_metadata: bool,
866 pub preserve_exif: bool,
868 pub blur: Option<f32>,
873 pub sharpen: Option<f32>,
879 pub grayscale: bool,
886 pub without_enlargement: bool,
894 pub crop: Option<CropRegion>,
900 pub deadline: Option<Duration>,
912}
913
914impl Default for TransformOptions {
915 fn default() -> Self {
916 Self {
917 width: None,
918 height: None,
919 fit: None,
920 position: None,
921 format: None,
922 quality: None,
923 optimize: OptimizeMode::None,
924 target_quality: None,
925 background: None,
926 rotate: Rotation::DEG_0,
927 auto_orient: true,
928 strip_metadata: true,
929 preserve_exif: false,
930 blur: None,
931 sharpen: None,
932 grayscale: false,
933 without_enlargement: false,
934 crop: None,
935 deadline: None,
936 }
937 }
938}
939
940impl TransformOptions {
941 fn svg_passthrough_unsupported_option(&self) -> Option<&'static str> {
947 if self.width.is_some() {
948 return Some("width");
949 }
950 if self.height.is_some() {
951 return Some("height");
952 }
953 if !self.rotate.is_identity() {
954 return Some("rotate");
955 }
956 if self.grayscale {
957 return Some("grayscale");
958 }
959 if self.background.is_some() {
960 return Some("background");
961 }
962 None
963 }
964
965 pub(crate) fn validate_without_input(&self) -> Result<(), TransformError> {
975 validate_dimension("width", self.width)?;
976 validate_dimension("height", self.height)?;
977 validate_quality(self.quality)?;
978 validate_target_quality(self.target_quality)?;
979 validate_blur(self.blur)?;
980 validate_sharpen(self.sharpen)?;
981 if let Some(crop) = self.crop
982 && (crop.width == 0 || crop.height == 0)
983 {
984 return Err(TransformError::InvalidOptions(
985 "crop width and height must be greater than zero".to_string(),
986 ));
987 }
988
989 let has_bounded_resize = self.width.is_some() && self.height.is_some();
990
991 if self.fit.is_some() && !has_bounded_resize {
992 return Err(TransformError::InvalidOptions(
993 "fit requires both width and height".to_string(),
994 ));
995 }
996
997 if self.position.is_some() && !has_bounded_resize {
998 return Err(TransformError::InvalidOptions(
999 "position requires both width and height".to_string(),
1000 ));
1001 }
1002
1003 if self.without_enlargement && self.width.is_none() && self.height.is_none() {
1007 return Err(TransformError::InvalidOptions(
1008 "withoutEnlargement requires width or height".to_string(),
1009 ));
1010 }
1011
1012 if self.preserve_exif && self.strip_metadata {
1013 return Err(TransformError::InvalidOptions(
1014 "preserveExif requires stripMetadata to be false".to_string(),
1015 ));
1016 }
1017
1018 Ok(())
1019 }
1020
1021 pub(crate) fn normalize(
1028 self,
1029 input_media_type: MediaType,
1030 ) -> Result<NormalizedTransformOptions, TransformError> {
1031 self.validate_without_input()?;
1032
1033 let has_bounded_resize = self.width.is_some() && self.height.is_some();
1034
1035 let format = self
1038 .format
1039 .unwrap_or_else(|| input_media_type.default_output());
1040 let optimize = self.optimize;
1041
1042 if optimize != OptimizeMode::None && !format.supports_optimization() {
1043 return Err(TransformError::InvalidOptions(format!(
1044 "optimization is not supported for {} output",
1045 format.as_name()
1046 )));
1047 }
1048
1049 if optimize == OptimizeMode::Lossy && !format.supports_lossy_optimization() {
1050 return Err(TransformError::InvalidOptions(format!(
1051 "lossy optimization requires jpeg, webp, or avif output, got {}",
1052 format.as_name()
1053 )));
1054 }
1055
1056 if self.preserve_exif && format == MediaType::Svg {
1057 return Err(TransformError::InvalidOptions(
1058 "preserveExif is not supported with SVG output".to_string(),
1059 ));
1060 }
1061
1062 if input_media_type == MediaType::Svg
1068 && format == MediaType::Svg
1069 && let Some(option) = self.svg_passthrough_unsupported_option()
1070 {
1071 return Err(TransformError::InvalidOptions(format!(
1072 "{option} is not supported with SVG output; choose a raster output format such as png"
1073 )));
1074 }
1075
1076 if self.quality.is_some() && !format.is_lossy() {
1077 return Err(TransformError::InvalidOptions(
1078 "quality requires a lossy output format".to_string(),
1079 ));
1080 }
1081
1082 if self.quality.is_some() && optimize == OptimizeMode::Lossless {
1083 return Err(TransformError::InvalidOptions(
1084 "quality cannot be combined with optimize=lossless".to_string(),
1085 ));
1086 }
1087
1088 if self.target_quality.is_some()
1089 && matches!(optimize, OptimizeMode::None | OptimizeMode::Lossless)
1090 {
1091 return Err(TransformError::InvalidOptions(
1092 "targetQuality requires optimize=auto or optimize=lossy".to_string(),
1093 ));
1094 }
1095
1096 if self.target_quality.is_some() && !format.supports_lossy_optimization() {
1097 return Err(TransformError::InvalidOptions(
1098 "targetQuality requires jpeg, webp, or avif output".to_string(),
1099 ));
1100 }
1101
1102 let fit = if has_bounded_resize {
1103 Some(self.fit.unwrap_or(Fit::Contain))
1104 } else {
1105 None
1106 };
1107
1108 Ok(NormalizedTransformOptions {
1109 width: self.width,
1110 height: self.height,
1111 fit,
1112 position: self.position.unwrap_or(Position::Center),
1113 format,
1114 quality: self.quality,
1115 optimize,
1116 target_quality: self.target_quality,
1117 background: self.background,
1118 rotate: self.rotate,
1119 auto_orient: self.auto_orient,
1120 metadata_policy: normalize_metadata_policy(
1121 self.strip_metadata,
1122 self.preserve_exif,
1123 optimize,
1124 format,
1125 ),
1126 blur: self.blur,
1127 sharpen: self.sharpen,
1128 grayscale: self.grayscale,
1129 without_enlargement: self.without_enlargement,
1130 crop: self.crop,
1131 deadline: self.deadline,
1132 })
1133 }
1134}
1135
1136#[derive(Debug, Clone, PartialEq)]
1138#[non_exhaustive]
1139pub(crate) struct NormalizedTransformOptions {
1140 pub width: Option<u32>,
1142 pub height: Option<u32>,
1144 pub fit: Option<Fit>,
1146 pub position: Position,
1148 pub format: MediaType,
1150 pub quality: Option<u8>,
1152 pub optimize: OptimizeMode,
1154 pub target_quality: Option<TargetQuality>,
1156 pub background: Option<Rgba8>,
1158 pub rotate: Rotation,
1160 pub auto_orient: bool,
1162 pub metadata_policy: MetadataPolicy,
1164 pub blur: Option<f32>,
1166 pub sharpen: Option<f32>,
1168 pub grayscale: bool,
1170 pub without_enlargement: bool,
1172 pub crop: Option<CropRegion>,
1174 pub deadline: Option<Duration>,
1176}
1177
1178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1193#[non_exhaustive]
1194pub enum Fit {
1195 Contain,
1200 Cover,
1202 Fill,
1204 Inside,
1213}
1214
1215impl Fit {
1216 #[must_use]
1218 pub const fn as_name(self) -> &'static str {
1219 match self {
1220 Self::Contain => "contain",
1221 Self::Cover => "cover",
1222 Self::Fill => "fill",
1223 Self::Inside => "inside",
1224 }
1225 }
1226}
1227
1228impl FromStr for Fit {
1229 type Err = String;
1230
1231 fn from_str(value: &str) -> Result<Self, Self::Err> {
1232 match value {
1233 "contain" => Ok(Self::Contain),
1234 "cover" => Ok(Self::Cover),
1235 "fill" => Ok(Self::Fill),
1236 "inside" => Ok(Self::Inside),
1237 _ => Err(format!("unsupported fit mode `{value}`")),
1238 }
1239 }
1240}
1241
1242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1257#[non_exhaustive]
1258pub enum Position {
1259 Center,
1261 Top,
1263 Right,
1265 Bottom,
1267 Left,
1269 TopLeft,
1271 TopRight,
1273 BottomLeft,
1275 BottomRight,
1277}
1278
1279impl Position {
1280 #[must_use]
1282 pub const fn as_name(self) -> &'static str {
1283 match self {
1284 Self::Center => "center",
1285 Self::Top => "top",
1286 Self::Right => "right",
1287 Self::Bottom => "bottom",
1288 Self::Left => "left",
1289 Self::TopLeft => "top-left",
1290 Self::TopRight => "top-right",
1291 Self::BottomLeft => "bottom-left",
1292 Self::BottomRight => "bottom-right",
1293 }
1294 }
1295}
1296
1297impl FromStr for Position {
1298 type Err = String;
1299
1300 fn from_str(value: &str) -> Result<Self, Self::Err> {
1301 match value {
1302 "center" => Ok(Self::Center),
1303 "top" => Ok(Self::Top),
1304 "right" => Ok(Self::Right),
1305 "bottom" => Ok(Self::Bottom),
1306 "left" => Ok(Self::Left),
1307 "top-left" => Ok(Self::TopLeft),
1308 "top-right" => Ok(Self::TopRight),
1309 "bottom-left" => Ok(Self::BottomLeft),
1310 "bottom-right" => Ok(Self::BottomRight),
1311 _ => Err(format!("unsupported position `{value}`")),
1312 }
1313 }
1314}
1315
1316#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1351pub struct Rotation(u16);
1352
1353impl Rotation {
1354 pub const DEG_0: Self = Self(0);
1356 pub const DEG_90: Self = Self(90);
1358 pub const DEG_180: Self = Self(180);
1360 pub const DEG_270: Self = Self(270);
1362
1363 #[must_use]
1368 pub const fn from_degrees(degrees: i32) -> Self {
1369 let wrapped = degrees % 360;
1370 let normalized = if wrapped < 0 { wrapped + 360 } else { wrapped };
1371 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
1372 Self(normalized as u16)
1373 }
1374
1375 #[must_use]
1377 pub const fn as_degrees(self) -> u16 {
1378 self.0
1379 }
1380
1381 #[must_use]
1383 pub const fn is_identity(self) -> bool {
1384 self.0 == 0
1385 }
1386
1387 #[must_use]
1392 pub const fn quarter_turns(self) -> Option<u8> {
1393 if self.0.is_multiple_of(90) {
1394 #[allow(clippy::cast_possible_truncation)]
1395 Some((self.0 / 90) as u8)
1396 } else {
1397 None
1398 }
1399 }
1400}
1401
1402impl fmt::Display for Rotation {
1403 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1404 write!(f, "{}", self.0)
1405 }
1406}
1407
1408impl FromStr for Rotation {
1409 type Err = String;
1410
1411 fn from_str(value: &str) -> Result<Self, Self::Err> {
1412 match value.parse::<i64>() {
1417 Ok(degrees) => Ok(Self::from_degrees((degrees % 360) as i32)),
1418 Err(_) => Err(format!(
1419 "unsupported rotation `{value}`: expected a whole number of degrees"
1420 )),
1421 }
1422 }
1423}
1424
1425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1444pub struct Rgba8 {
1445 pub r: u8,
1447 pub g: u8,
1449 pub b: u8,
1451 pub a: u8,
1453}
1454
1455impl Rgba8 {
1456 pub fn from_hex(value: &str) -> Result<Self, String> {
1463 fn rule(value: &str) -> String {
1464 format!(
1465 "unsupported color `{value}`: a color is six or eight hexadecimal digits with no leading `#`, as in ffffff or ffffffaa"
1466 )
1467 }
1468
1469 if !value.is_ascii() || (value.len() != 6 && value.len() != 8) {
1470 return Err(rule(value));
1471 }
1472
1473 let r = u8::from_str_radix(&value[0..2], 16).map_err(|_| rule(value))?;
1474 let g = u8::from_str_radix(&value[2..4], 16).map_err(|_| rule(value))?;
1475 let b = u8::from_str_radix(&value[4..6], 16).map_err(|_| rule(value))?;
1476 let a = if value.len() == 8 {
1477 u8::from_str_radix(&value[6..8], 16).map_err(|_| rule(value))?
1478 } else {
1479 u8::MAX
1480 };
1481
1482 Ok(Self { r, g, b, a })
1483 }
1484}
1485
1486#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1494#[non_exhaustive]
1495pub(crate) enum MetadataPolicy {
1496 StripAll,
1498 KeepAll,
1500 PreserveIcc,
1502 PreserveExif,
1504}
1505
1506pub(crate) fn resolve_metadata_flags(
1525 strip: Option<bool>,
1526 keep: Option<bool>,
1527 preserve_exif: Option<bool>,
1528) -> Result<(bool, bool), TransformError> {
1529 let keep = keep.unwrap_or(false);
1530 let preserve_exif = preserve_exif.unwrap_or(false);
1531
1532 if keep && preserve_exif {
1533 return Err(TransformError::InvalidOptions(
1534 "keepMetadata and preserveExif cannot both be true".to_string(),
1535 ));
1536 }
1537
1538 let strip_metadata = if keep || preserve_exif {
1539 false
1540 } else {
1541 strip.unwrap_or(true)
1542 };
1543
1544 Ok((strip_metadata, preserve_exif))
1545}
1546
1547#[derive(Debug, Clone, PartialEq, Eq)]
1564#[non_exhaustive]
1565pub enum TransformError {
1566 InvalidInput(String),
1568 InvalidOptions(String),
1570 UnsupportedInputMediaType(String),
1572 UnsupportedOutputMediaType(MediaType),
1574 DecodeFailed(String),
1576 EncodeFailed(String),
1578 CapabilityMissing(String),
1580 LimitExceeded(String),
1582}
1583
1584#[cfg(any(feature = "server", feature = "wasm"))]
1599pub(crate) fn single_line(message: &str) -> std::borrow::Cow<'_, str> {
1600 let trimmed = message.trim();
1601 if !trimmed.contains(breaks_a_line) {
1602 return std::borrow::Cow::Borrowed(trimmed);
1603 }
1604 std::borrow::Cow::Owned(trimmed.split_whitespace().collect::<Vec<_>>().join(" "))
1605}
1606
1607#[cfg(any(feature = "server", feature = "wasm"))]
1612fn breaks_a_line(c: char) -> bool {
1613 (c.is_whitespace() && c != ' ') || c.is_control()
1614}
1615
1616impl fmt::Display for TransformError {
1617 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1618 match self {
1619 Self::InvalidInput(reason) => write!(f, "invalid input: {reason}"),
1620 Self::InvalidOptions(reason) => write!(f, "invalid transform options: {reason}"),
1621 Self::UnsupportedInputMediaType(reason) => {
1622 write!(f, "unsupported input media type: {reason}")
1623 }
1624 Self::UnsupportedOutputMediaType(media_type) => match media_type {
1630 MediaType::Svg => write!(
1631 f,
1632 "svg output requires an svg input; choose a raster output format such as png, jpeg, webp, or avif"
1633 ),
1634 MediaType::Gif => write!(
1635 f,
1636 "gif is an input-only format; choose an output format such as png, jpeg, webp, or avif"
1637 ),
1638 other => write!(f, "unsupported output media type: {other}"),
1639 },
1640 Self::DecodeFailed(reason) => write!(f, "decode failed: {reason}"),
1641 Self::EncodeFailed(reason) => write!(f, "encode failed: {reason}"),
1642 Self::CapabilityMissing(reason) => write!(f, "missing capability: {reason}"),
1643 Self::LimitExceeded(reason) => write!(f, "limit exceeded: {reason}"),
1644 }
1645 }
1646}
1647
1648impl Error for TransformError {}
1649
1650#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1664#[non_exhaustive]
1665pub enum MetadataKind {
1666 Xmp,
1668 Iptc,
1670 Exif,
1672 Icc,
1674}
1675
1676impl fmt::Display for MetadataKind {
1677 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1678 match self {
1679 Self::Xmp => f.write_str("XMP"),
1680 Self::Iptc => f.write_str("IPTC"),
1681 Self::Exif => f.write_str("EXIF"),
1682 Self::Icc => f.write_str("ICC profile"),
1683 }
1684 }
1685}
1686
1687#[derive(Debug, Clone, PartialEq)]
1703#[non_exhaustive]
1704pub enum TransformWarning {
1705 MetadataDropped(MetadataKind),
1708 OrientationDropped {
1711 orientation: u16,
1713 },
1714 TargetQualityNotReached {
1720 target: TargetQuality,
1722 achieved: f32,
1724 quality: u8,
1727 },
1728}
1729
1730impl fmt::Display for TransformWarning {
1731 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1732 match self {
1733 Self::MetadataDropped(kind) => write!(
1734 f,
1735 "{kind} metadata was present in the input but could not be preserved by the output encoder"
1736 ),
1737 Self::OrientationDropped { orientation } => write!(
1738 f,
1739 "the input carries EXIF orientation {orientation}; with autoOrient off and the metadata stripped the output records it neither in its pixels nor in its metadata, so it displays rotated. Keep the metadata to preserve the tag, or leave autoOrient on to apply it to the pixels"
1740 ),
1741 Self::TargetQualityNotReached {
1742 target,
1743 achieved,
1744 quality,
1745 } => {
1746 let metric = target.metric.as_name();
1747 if *quality < 100 {
1748 write!(
1749 f,
1750 "the lossy encode did not reach {target} within the quality cap of {quality}: at that quality it reached {metric} {achieved:.3}. Raise the cap or lower the target"
1751 )
1752 } else {
1753 write!(
1754 f,
1755 "the lossy encode did not reach {target} at quality 100, where it reached {metric} {achieved:.3}. The quality range is sampled rather than scanned, so a setting the search did not try may still reach it; lower the target for one it will find"
1756 )
1757 }
1758 }
1759 }
1760 }
1761}
1762
1763#[derive(Debug)]
1768#[must_use]
1769#[non_exhaustive]
1770pub struct TransformResult {
1771 pub artifact: Artifact,
1773 pub warnings: Vec<TransformWarning>,
1775}
1776
1777#[must_use = "this function returns the detected artifact without side effects"]
1833pub fn sniff_artifact(input: RawArtifact) -> Result<Artifact, TransformError> {
1834 let (media_type, metadata) = detect_artifact(&input.bytes)?;
1835
1836 if let Some(declared_media_type) = input.declared_media_type
1837 && declared_media_type != media_type
1838 {
1839 return Err(TransformError::InvalidInput(
1840 "declared media type does not match detected media type".to_string(),
1841 ));
1842 }
1843
1844 Ok(Artifact::new(input.bytes, media_type, metadata))
1845}
1846
1847fn validate_dimension(name: &str, value: Option<u32>) -> Result<(), TransformError> {
1848 if matches!(value, Some(0)) {
1849 return Err(TransformError::InvalidOptions(format!(
1850 "{name} must be greater than zero"
1851 )));
1852 }
1853
1854 Ok(())
1855}
1856
1857fn validate_quality(value: Option<u8>) -> Result<(), TransformError> {
1858 match value {
1859 Some(value) => validate_quality_value(i64::from(value))
1860 .map(|_| ())
1861 .map_err(|message| TransformError::InvalidOptions(message.to_string())),
1862 None => Ok(()),
1863 }
1864}
1865
1866pub(crate) fn validate_quality_value(value: i64) -> Result<u8, &'static str> {
1874 match value {
1875 1..=100 => Ok(value as u8),
1876 _ => Err("quality must be between 1 and 100"),
1877 }
1878}
1879
1880fn validate_target_quality(value: Option<TargetQuality>) -> Result<(), TransformError> {
1881 let Some(value) = value else {
1882 return Ok(());
1883 };
1884
1885 if !value.value.is_finite() {
1886 return Err(TransformError::InvalidOptions(
1887 "targetQuality must be finite".to_string(),
1888 ));
1889 }
1890
1891 match value.metric {
1892 QualityMetric::Ssim if !(0.0..=1.0).contains(&value.value) || value.value == 0.0 => {
1893 Err(TransformError::InvalidOptions(
1894 "ssim targetQuality must be greater than 0.0 and at most 1.0".to_string(),
1895 ))
1896 }
1897 QualityMetric::Psnr if value.value <= 0.0 => Err(TransformError::InvalidOptions(
1898 "psnr targetQuality must be greater than 0".to_string(),
1899 )),
1900 _ => Ok(()),
1901 }
1902}
1903
1904fn validate_blur(value: Option<f32>) -> Result<(), TransformError> {
1905 if let Some(sigma) = value
1906 && !(0.1..=100.0).contains(&sigma)
1907 {
1908 return Err(TransformError::InvalidOptions(
1909 "blur sigma must be between 0.1 and 100.0".to_string(),
1910 ));
1911 }
1912
1913 Ok(())
1914}
1915
1916fn validate_sharpen(value: Option<f32>) -> Result<(), TransformError> {
1917 if let Some(sigma) = value
1918 && !(0.1..=100.0).contains(&sigma)
1919 {
1920 return Err(TransformError::InvalidOptions(
1921 "sharpen sigma must be between 0.1 and 100.0".to_string(),
1922 ));
1923 }
1924
1925 Ok(())
1926}
1927
1928pub(crate) fn deserialize_quality<'de, D>(deserializer: D) -> Result<Option<u8>, D::Error>
1935where
1936 D: serde::Deserializer<'de>,
1937{
1938 deserialize_ranged(deserializer, |value| match u8::try_from(value) {
1942 Ok(quality) => Ok(quality),
1943 Err(_) => {
1944 Err(validate_quality_value(value).expect_err("a value outside u8 is outside 1..=100"))
1945 }
1946 })
1947}
1948
1949pub(crate) fn deserialize_width<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
1951where
1952 D: serde::Deserializer<'de>,
1953{
1954 deserialize_ranged(deserializer, validate_width_value)
1955}
1956
1957pub(crate) fn deserialize_height<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
1959where
1960 D: serde::Deserializer<'de>,
1961{
1962 deserialize_ranged(deserializer, validate_height_value)
1963}
1964
1965fn deserialize_ranged<'de, D, T>(
1966 deserializer: D,
1967 validate: impl FnOnce(i64) -> Result<T, &'static str>,
1968) -> Result<Option<T>, D::Error>
1969where
1970 D: serde::Deserializer<'de>,
1971{
1972 use serde::Deserialize as _;
1973 use serde::de::Error as _;
1974 match Option::<i64>::deserialize(deserializer)? {
1975 None => Ok(None),
1976 Some(value) => validate(value).map(Some).map_err(D::Error::custom),
1977 }
1978}
1979
1980pub(crate) fn deserialize_rotation_degrees<'de, D>(deserializer: D) -> Result<Option<i32>, D::Error>
1986where
1987 D: serde::Deserializer<'de>,
1988{
1989 use serde::Deserialize as _;
1990 Ok(Option::<i64>::deserialize(deserializer)?.map(|degrees| (degrees % 360) as i32))
1991}
1992
1993pub(crate) fn validate_width_value(value: i64) -> Result<u32, &'static str> {
2002 dimension_value(
2003 value,
2004 "width must be greater than zero",
2005 "width is too large to be a number of pixels",
2006 )
2007}
2008
2009pub(crate) fn validate_height_value(value: i64) -> Result<u32, &'static str> {
2011 dimension_value(
2012 value,
2013 "height must be greater than zero",
2014 "height is too large to be a number of pixels",
2015 )
2016}
2017
2018#[cfg(any(feature = "cli", feature = "server"))]
2024pub(crate) fn validate_watermark_margin_value(value: i64) -> Result<u32, &'static str> {
2025 dimension_value(
2026 value,
2027 "watermark margin must not be negative",
2028 "watermark margin is too large to be a number of pixels",
2029 )
2030}
2031
2032fn dimension_value(
2033 value: i64,
2034 not_positive: &'static str,
2035 too_large: &'static str,
2036) -> Result<u32, &'static str> {
2037 match u32::try_from(value) {
2038 Ok(pixels) => Ok(pixels),
2039 Err(_) if value <= 0 => Err(not_positive),
2040 Err(_) => Err(too_large),
2041 }
2042}
2043
2044#[cfg(any(feature = "cli", feature = "server"))]
2050pub(crate) fn validate_watermark_opacity_value(value: i64) -> Result<u8, &'static str> {
2051 match value {
2052 1..=100 => Ok(value as u8),
2053 _ => Err("watermark opacity must be between 1 and 100"),
2054 }
2055}
2056
2057pub(crate) fn validate_watermark_opacity(opacity: u8) -> Result<(), &'static str> {
2066 if opacity == 0 || opacity > 100 {
2067 return Err("watermark opacity must be between 1 and 100");
2068 }
2069
2070 Ok(())
2071}
2072
2073fn validate_watermark(wm: &WatermarkInput) -> Result<(), TransformError> {
2074 validate_watermark_opacity(wm.opacity)
2075 .map_err(|message| TransformError::InvalidOptions(message.to_string()))?;
2076
2077 if !wm.image.media_type.is_raster() {
2078 return Err(TransformError::InvalidOptions(
2079 "watermark image must be a raster format".to_string(),
2080 ));
2081 }
2082
2083 Ok(())
2084}
2085
2086fn normalize_metadata_policy(
2099 strip_metadata: bool,
2100 preserve_exif: bool,
2101 optimize: OptimizeMode,
2102 format: MediaType,
2103) -> MetadataPolicy {
2104 if preserve_exif {
2105 MetadataPolicy::PreserveExif
2106 } else if strip_metadata && optimize != OptimizeMode::None && format.supports_icc_profile() {
2107 MetadataPolicy::PreserveIcc
2108 } else if strip_metadata {
2109 MetadataPolicy::StripAll
2110 } else {
2111 MetadataPolicy::KeepAll
2112 }
2113}
2114
2115fn detect_artifact(bytes: &[u8]) -> Result<(MediaType, ArtifactMetadata), TransformError> {
2116 if is_png(bytes) {
2117 return Ok((MediaType::Png, sniff_png(bytes)?));
2118 }
2119
2120 if is_jpeg(bytes) {
2121 return Ok((MediaType::Jpeg, sniff_jpeg(bytes)?));
2122 }
2123
2124 if is_webp(bytes) {
2125 return Ok((MediaType::Webp, sniff_webp(bytes)?));
2126 }
2127
2128 if is_avif(bytes) {
2129 return Ok((MediaType::Avif, sniff_avif(bytes)?));
2130 }
2131
2132 if is_bmp(bytes) {
2133 return Ok((MediaType::Bmp, sniff_bmp(bytes)?));
2134 }
2135
2136 if is_tiff(bytes) {
2137 return Ok((MediaType::Tiff, sniff_tiff(bytes)?));
2138 }
2139
2140 if is_gif(bytes) {
2141 return Ok((MediaType::Gif, sniff_gif(bytes)?));
2142 }
2143
2144 if is_svg(bytes) {
2147 return Ok((MediaType::Svg, sniff_svg(bytes)));
2148 }
2149
2150 Err(TransformError::UnsupportedInputMediaType(format!(
2156 "unknown file signature ({} bytes)",
2157 bytes.len()
2158 )))
2159}
2160
2161fn is_png(bytes: &[u8]) -> bool {
2162 bytes.starts_with(b"\x89PNG\r\n\x1a\n")
2163}
2164
2165fn is_jpeg(bytes: &[u8]) -> bool {
2166 bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
2167}
2168
2169fn is_webp(bytes: &[u8]) -> bool {
2170 bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP"
2171}
2172
2173fn is_avif(bytes: &[u8]) -> bool {
2174 bytes.len() >= 16 && &bytes[4..8] == b"ftyp" && has_avif_brand(&bytes[8..])
2175}
2176
2177fn is_svg(bytes: &[u8]) -> bool {
2186 svg_root_element(bytes).is_some()
2187}
2188
2189fn svg_root_element(bytes: &[u8]) -> Option<&str> {
2195 let text = std::str::from_utf8(bytes).ok()?;
2196
2197 let mut remaining = text.strip_prefix('\u{FEFF}').unwrap_or(text);
2199 let mut seen_doctype = false;
2200
2201 loop {
2202 remaining = remaining.trim_start();
2203
2204 if let Some(rest) = remaining.strip_prefix("<!--") {
2205 let end = rest.find("-->")?;
2206 remaining = &rest[end + 3..];
2207 continue;
2208 }
2209
2210 if let Some(rest) = remaining.strip_prefix("<?") {
2213 let end = rest.find("?>")?;
2214 remaining = &rest[end + 2..];
2215 continue;
2216 }
2217
2218 if !seen_doctype && let Some(rest) = remaining.strip_prefix("<!DOCTYPE") {
2219 let after = skip_doctype(rest)?;
2220 seen_doctype = true;
2221 remaining = after;
2222 continue;
2223 }
2224
2225 break;
2226 }
2227
2228 let is_root = remaining.starts_with("<svg")
2229 && remaining
2230 .as_bytes()
2231 .get(4)
2232 .is_some_and(|&b| b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'>');
2233 is_root.then_some(remaining)
2234}
2235
2236fn skip_doctype(rest: &str) -> Option<&str> {
2243 let bytes = rest.as_bytes();
2244 let mut quote: Option<u8> = None;
2245 let mut in_subset = false;
2246
2247 for (index, &byte) in bytes.iter().enumerate() {
2248 match quote {
2249 Some(open) => {
2250 if byte == open {
2251 quote = None;
2252 }
2253 }
2254 None => match byte {
2255 b'"' | b'\'' => quote = Some(byte),
2256 b'[' => in_subset = true,
2257 b']' => in_subset = false,
2258 b'>' if !in_subset => return Some(&rest[index + 1..]),
2259 _ => {}
2260 },
2261 }
2262 }
2263
2264 None
2265}
2266
2267fn sniff_svg(bytes: &[u8]) -> ArtifactMetadata {
2275 let size = svg_root_element(bytes).and_then(svg_intrinsic_size);
2276 ArtifactMetadata {
2277 width: size.map(|(width, _)| width),
2278 height: size.map(|(_, height)| height),
2279 frame_count: 1,
2280 duration: None,
2281 has_alpha: Some(true),
2282 orientation: None,
2283 }
2284}
2285
2286fn svg_intrinsic_size(root: &str) -> Option<(u32, u32)> {
2294 let tag = svg_root_tag(root)?;
2295 let width = root_attribute(tag, "width").and_then(svg_length_px);
2296 let height = root_attribute(tag, "height").and_then(svg_length_px);
2297 let view_box = root_attribute(tag, "viewBox").and_then(parse_view_box);
2298
2299 let (width, height) = match (width, height, view_box) {
2300 (Some(width), Some(height), _) => (width, height),
2301 (Some(width), None, Some((box_width, box_height))) => {
2302 (width, width * box_height / box_width)
2303 }
2304 (None, Some(height), Some((box_width, box_height))) => {
2305 (height * box_width / box_height, height)
2306 }
2307 (None, None, Some(size)) => size,
2308 _ => return None,
2309 };
2310
2311 Some((to_dimension(width)?, to_dimension(height)?))
2312}
2313
2314fn svg_root_tag(root: &str) -> Option<&str> {
2319 let mut quote: Option<u8> = None;
2320 for (index, &byte) in root.as_bytes().iter().enumerate() {
2321 match quote {
2322 Some(open) => {
2323 if byte == open {
2324 quote = None;
2325 }
2326 }
2327 None => match byte {
2328 b'"' | b'\'' => quote = Some(byte),
2329 b'>' => return Some(&root[1..index]),
2330 _ => {}
2331 },
2332 }
2333 }
2334 None
2335}
2336
2337fn root_attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
2342 let bytes = tag.as_bytes();
2343 let mut index = 0;
2344
2345 while index < bytes.len() && !bytes[index].is_ascii_whitespace() {
2347 index += 1;
2348 }
2349
2350 loop {
2351 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
2352 index += 1;
2353 }
2354 if index >= bytes.len() {
2355 return None;
2356 }
2357
2358 let name_start = index;
2359 while index < bytes.len()
2360 && bytes[index] != b'='
2361 && !bytes[index].is_ascii_whitespace()
2362 && bytes[index] != b'/'
2363 {
2364 index += 1;
2365 }
2366 let attribute = &tag[name_start..index];
2367 if attribute.is_empty() {
2368 index += 1;
2370 continue;
2371 }
2372
2373 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
2374 index += 1;
2375 }
2376 if index >= bytes.len() || bytes[index] != b'=' {
2377 continue;
2378 }
2379 index += 1;
2380 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
2381 index += 1;
2382 }
2383
2384 let "e = bytes.get(index)?;
2385 if quote != b'"' && quote != b'\'' {
2386 return None;
2387 }
2388 index += 1;
2389 let value_start = index;
2390 while index < bytes.len() && bytes[index] != quote {
2391 index += 1;
2392 }
2393 if index >= bytes.len() {
2394 return None;
2395 }
2396 let value = &tag[value_start..index];
2397 index += 1;
2398
2399 if attribute == name {
2400 return Some(value);
2401 }
2402 }
2403}
2404
2405fn svg_length_px(value: &str) -> Option<f64> {
2411 let value = value.trim();
2412 let split = value
2413 .find(|c: char| !matches!(c, '0'..='9' | '.' | '+' | '-' | 'e' | 'E'))
2414 .unwrap_or(value.len());
2415 let number: f64 = value[..split].parse().ok()?;
2416 let scale = match value[split..].trim().to_ascii_lowercase().as_str() {
2417 "" | "px" => 1.0,
2418 "pt" => 96.0 / 72.0,
2419 "pc" => 16.0,
2420 "in" => 96.0,
2421 "cm" => 96.0 / 2.54,
2422 "mm" => 96.0 / 25.4,
2423 "q" => 96.0 / 101.6,
2424 _ => return None,
2425 };
2426 let pixels = number * scale;
2427 (pixels.is_finite() && pixels > 0.0).then_some(pixels)
2428}
2429
2430fn parse_view_box(value: &str) -> Option<(f64, f64)> {
2432 let numbers: Vec<f64> = value
2433 .split([' ', '\t', '\n', '\r', ','])
2434 .filter(|part| !part.is_empty())
2435 .map(str::parse)
2436 .collect::<Result<_, _>>()
2437 .ok()?;
2438 let [_, _, width, height] = numbers[..] else {
2439 return None;
2440 };
2441 (width.is_finite() && width > 0.0 && height.is_finite() && height > 0.0)
2442 .then_some((width, height))
2443}
2444
2445fn to_dimension(value: f64) -> Option<u32> {
2451 if !(value.is_finite() && value >= 1.0 && value < f64::from(u32::MAX)) {
2452 return None;
2453 }
2454 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2455 Some(value as u32)
2456}
2457
2458fn is_bmp(bytes: &[u8]) -> bool {
2460 bytes.len() >= 26 && bytes[0] == 0x42 && bytes[1] == 0x4D
2461}
2462
2463fn sniff_bmp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
2470 if bytes.len() < 30 {
2471 return Err(TransformError::DecodeFailed(
2472 "bmp file is too short".to_string(),
2473 ));
2474 }
2475
2476 let width = u32::from_le_bytes([bytes[18], bytes[19], bytes[20], bytes[21]]);
2477 let raw_height = i32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]);
2478 let height = raw_height.unsigned_abs();
2479 let bits_per_pixel = u16::from_le_bytes([bytes[28], bytes[29]]);
2480
2481 let has_alpha = bits_per_pixel == 32;
2482
2483 Ok(ArtifactMetadata {
2484 width: Some(width),
2485 height: Some(height),
2486 frame_count: 1,
2487 duration: None,
2488 has_alpha: Some(has_alpha),
2489 orientation: None,
2490 })
2491}
2492
2493fn is_tiff(bytes: &[u8]) -> bool {
2497 bytes.len() >= 4
2498 && ((bytes[0] == b'I' && bytes[1] == b'I' && bytes[2] == 0x2A && bytes[3] == 0x00)
2499 || (bytes[0] == b'M' && bytes[1] == b'M' && bytes[2] == 0x00 && bytes[3] == 0x2A))
2500}
2501
2502fn is_gif(bytes: &[u8]) -> bool {
2503 bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")
2504}
2505
2506fn sniff_gif(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
2519 if bytes.len() < 13 {
2521 return Err(TransformError::DecodeFailed(
2522 "gif file is too short".to_string(),
2523 ));
2524 }
2525
2526 let width = u32::from(read_u16_le(&bytes[6..8])?);
2527 let height = u32::from(read_u16_le(&bytes[8..10])?);
2528 let packed = bytes[10];
2529
2530 let mut offset = 13usize;
2531 if packed & 0b1000_0000 != 0 {
2534 let entries = 1usize << ((packed & 0b0000_0111) + 1);
2535 offset = offset.saturating_add(entries * 3);
2536 }
2537
2538 let mut frame_count = 0u32;
2539 let mut has_alpha = false;
2540
2541 while offset < bytes.len() {
2542 match bytes[offset] {
2543 0x3B => break,
2545 0x21 => {
2547 if offset + 1 >= bytes.len() {
2548 break;
2549 }
2550 let label = bytes[offset + 1];
2551 let mut cursor = offset + 2;
2552 if label == 0xF9
2555 && cursor + 2 < bytes.len()
2556 && bytes[cursor] >= 1
2557 && bytes[cursor + 1] & 0b0000_0001 != 0
2558 {
2559 has_alpha = true;
2560 }
2561 cursor = skip_gif_sub_blocks(bytes, cursor)?;
2562 offset = cursor;
2563 }
2564 0x2C => {
2567 frame_count = frame_count.saturating_add(1);
2568 if offset + 10 > bytes.len() {
2569 break;
2570 }
2571 let local_packed = bytes[offset + 9];
2572 let mut cursor = offset + 10;
2573 if local_packed & 0b1000_0000 != 0 {
2574 let entries = 1usize << ((local_packed & 0b0000_0111) + 1);
2575 cursor = cursor.saturating_add(entries * 3);
2576 }
2577 cursor = cursor.saturating_add(1);
2579 cursor = skip_gif_sub_blocks(bytes, cursor)?;
2580 offset = cursor;
2581 }
2582 other => {
2583 return Err(TransformError::DecodeFailed(format!(
2584 "gif file has an unknown block introducer 0x{other:02x}"
2585 )));
2586 }
2587 }
2588 }
2589
2590 if frame_count == 0 {
2591 return Err(TransformError::DecodeFailed(
2592 "gif file contains no image data".to_string(),
2593 ));
2594 }
2595
2596 Ok(ArtifactMetadata {
2597 width: Some(width),
2598 height: Some(height),
2599 frame_count,
2600 duration: None,
2601 has_alpha: Some(has_alpha),
2602 orientation: None,
2603 })
2604}
2605
2606fn skip_gif_sub_blocks(bytes: &[u8], mut offset: usize) -> Result<usize, TransformError> {
2612 loop {
2613 if offset >= bytes.len() {
2614 return Err(TransformError::DecodeFailed(
2615 "gif file ends inside a data block".to_string(),
2616 ));
2617 }
2618 let len = bytes[offset] as usize;
2619 offset += 1;
2620 if len == 0 {
2621 return Ok(offset);
2622 }
2623 offset = offset.saturating_add(len);
2624 }
2625}
2626
2627fn sniff_tiff(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
2629 let cursor = std::io::Cursor::new(bytes);
2630 let decoder = image::codecs::tiff::TiffDecoder::new(cursor)
2631 .map_err(|e| TransformError::DecodeFailed(format!("tiff decode: {e}")))?;
2632 let (width, height) = image::ImageDecoder::dimensions(&decoder);
2633 let color = image::ImageDecoder::color_type(&decoder);
2634 let has_alpha = matches!(
2635 color,
2636 image::ColorType::La8
2637 | image::ColorType::Rgba8
2638 | image::ColorType::La16
2639 | image::ColorType::Rgba16
2640 | image::ColorType::Rgba32F
2641 );
2642 Ok(ArtifactMetadata {
2643 width: Some(width),
2644 height: Some(height),
2645 frame_count: 1,
2646 duration: None,
2647 has_alpha: Some(has_alpha),
2648 orientation: exif_orientation(MediaType::Tiff, bytes),
2649 })
2650}
2651
2652fn sniff_png(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
2653 if bytes.len() < 29 {
2654 return Err(TransformError::DecodeFailed(
2655 "png file is too short".to_string(),
2656 ));
2657 }
2658
2659 if &bytes[12..16] != b"IHDR" {
2660 return Err(TransformError::DecodeFailed(
2661 "png file is missing an IHDR chunk".to_string(),
2662 ));
2663 }
2664
2665 let width = read_u32_be(&bytes[16..20])?;
2666 let height = read_u32_be(&bytes[20..24])?;
2667 let color_type = bytes[25];
2668 let ancillary = png_ancillary_facts(bytes);
2669 let has_alpha = match color_type {
2670 4 | 6 => Some(true),
2672 0 | 2 | 3 => Some(ancillary.has_trns),
2678 _ => None,
2679 };
2680
2681 Ok(ArtifactMetadata {
2682 width: Some(width),
2683 height: Some(height),
2684 frame_count: ancillary.frame_count,
2685 duration: None,
2686 has_alpha,
2687 orientation: exif_orientation(MediaType::Png, bytes),
2688 })
2689}
2690
2691struct PngAncillaryFacts {
2693 has_trns: bool,
2694 frame_count: u32,
2695}
2696
2697fn png_ancillary_facts(bytes: &[u8]) -> PngAncillaryFacts {
2704 let mut facts = PngAncillaryFacts {
2705 has_trns: false,
2706 frame_count: 1,
2707 };
2708
2709 let mut offset = 8 + 12 + 13;
2711 while offset + 8 <= bytes.len() {
2712 let Ok(length) = read_u32_be(&bytes[offset..offset + 4]) else {
2713 break;
2714 };
2715 let chunk_type = &bytes[offset + 4..offset + 8];
2716 match chunk_type {
2717 b"IDAT" | b"IEND" => break,
2718 b"tRNS" => facts.has_trns = true,
2719 b"acTL" if length >= 4 => {
2721 if let Ok(frames) = read_u32_be(&bytes[offset + 8..offset + 12]) {
2722 facts.frame_count = frames.max(1);
2723 }
2724 }
2725 _ => {}
2726 }
2727 let Some(next) = offset
2728 .checked_add(12)
2729 .and_then(|next| next.checked_add(length as usize))
2730 else {
2731 break;
2732 };
2733 offset = next;
2734 }
2735
2736 facts
2737}
2738
2739pub(crate) fn exif_orientation(media_type: MediaType, bytes: &[u8]) -> Option<u16> {
2757 let payload = match media_type {
2758 MediaType::Jpeg => jpeg_exif_payload(bytes)?,
2759 MediaType::Png => png_exif_payload(bytes)?,
2760 MediaType::Webp => webp_exif_payload(bytes)?,
2761 MediaType::Tiff => return tiff_orientation(bytes),
2763 MediaType::Avif => return avif_orientation(bytes),
2764 MediaType::Bmp | MediaType::Gif | MediaType::Svg => return None,
2765 };
2766 exif_orientation_from_payload(payload)
2767}
2768
2769fn png_exif_payload(bytes: &[u8]) -> Option<&[u8]> {
2776 let mut offset = 8usize;
2778 while offset + 8 <= bytes.len() {
2779 let length = usize::try_from(read_u32_be(bytes.get(offset..offset + 4)?).ok()?).ok()?;
2780 let chunk_type = bytes.get(offset + 4..offset + 8)?;
2781 let start = offset + 8;
2782 let end = start.checked_add(length)?;
2783 if end > bytes.len() {
2784 return None;
2785 }
2786 if chunk_type == b"eXIf" {
2787 return Some(strip_exif_prefix(bytes.get(start..end)?));
2788 }
2789 if chunk_type == b"IEND" {
2790 return None;
2791 }
2792 offset = end.checked_add(4)?;
2794 }
2795 None
2796}
2797
2798fn webp_exif_payload(bytes: &[u8]) -> Option<&[u8]> {
2804 let mut offset = 12usize;
2806 while offset + 8 <= bytes.len() {
2807 let chunk_tag = bytes.get(offset..offset + 4)?;
2808 let size = usize::try_from(read_u32_le(bytes.get(offset + 4..offset + 8)?).ok()?).ok()?;
2809 let start = offset + 8;
2810 let end = start.checked_add(size)?;
2811 if end > bytes.len() {
2812 return None;
2813 }
2814 if chunk_tag == b"EXIF" {
2815 return Some(strip_exif_prefix(bytes.get(start..end)?));
2816 }
2817 offset = end.checked_add(size % 2)?;
2819 }
2820 None
2821}
2822
2823fn strip_exif_prefix(payload: &[u8]) -> &[u8] {
2825 payload
2826 .strip_prefix(b"Exif\0\0".as_slice())
2827 .unwrap_or(payload)
2828}
2829
2830fn tiff_orientation(bytes: &[u8]) -> Option<u16> {
2836 let little_endian = match bytes.get(0..2)? {
2837 b"II" => true,
2838 b"MM" => false,
2839 _ => return None,
2840 };
2841
2842 let read_u16 = |offset: usize| -> Option<u16> {
2843 let raw: [u8; 2] = bytes.get(offset..offset + 2)?.try_into().ok()?;
2844 Some(if little_endian {
2845 u16::from_le_bytes(raw)
2846 } else {
2847 u16::from_be_bytes(raw)
2848 })
2849 };
2850 let read_u32 = |offset: usize| -> Option<u32> {
2851 let raw: [u8; 4] = bytes.get(offset..offset + 4)?.try_into().ok()?;
2852 Some(if little_endian {
2853 u32::from_le_bytes(raw)
2854 } else {
2855 u32::from_be_bytes(raw)
2856 })
2857 };
2858
2859 const ORIENTATION_TAG: u16 = 0x0112;
2860 const TYPE_SHORT: u16 = 3;
2861 const TYPE_LONG: u16 = 4;
2862
2863 let ifd = usize::try_from(read_u32(4)?).ok()?;
2864 let entry_count = usize::from(read_u16(ifd)?);
2865 for index in 0..entry_count {
2866 let entry = ifd.checked_add(2)?.checked_add(index.checked_mul(12)?)?;
2867 if read_u16(entry)? != ORIENTATION_TAG {
2868 continue;
2869 }
2870 return match read_u16(entry + 2)? {
2873 TYPE_SHORT => read_u16(entry + 8),
2874 TYPE_LONG => u16::try_from(read_u32(entry + 8)?).ok(),
2875 _ => None,
2876 };
2877 }
2878 None
2879}
2880
2881fn exif_orientation_from_payload(payload: &[u8]) -> Option<u16> {
2883 let exif = exif::Reader::new().read_raw(payload.to_vec()).ok()?;
2884 let field = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)?;
2885 match &field.value {
2886 exif::Value::Short(values) => values.first().copied(),
2887 exif::Value::Long(values) => values.first().and_then(|value| u16::try_from(*value).ok()),
2888 _ => None,
2889 }
2890}
2891
2892fn jpeg_exif_payload(bytes: &[u8]) -> Option<&[u8]> {
2894 const EXIF_PREFIX: &[u8] = b"Exif\0\0";
2895 const APP1: u8 = 0xE1;
2896
2897 let mut offset = 2;
2898 while offset + 1 < bytes.len() {
2899 if bytes[offset] != 0xFF {
2900 return None;
2901 }
2902 while offset < bytes.len() && bytes[offset] == 0xFF {
2903 offset += 1;
2904 }
2905
2906 let marker = *bytes.get(offset)?;
2907 offset += 1;
2908
2909 if marker == 0xD9 || marker == 0xDA {
2911 return None;
2912 }
2913 if (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
2915 continue;
2916 }
2917
2918 let length = read_u16_be(bytes.get(offset..offset + 2)?).ok()? as usize;
2919 if length < 2 || offset + length > bytes.len() {
2920 return None;
2921 }
2922 if marker == APP1
2923 && let Some(payload) = bytes[offset + 2..offset + length].strip_prefix(EXIF_PREFIX)
2924 {
2925 return Some(payload);
2926 }
2927 offset += length;
2928 }
2929
2930 None
2931}
2932
2933fn sniff_jpeg(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
2934 let mut offset = 2;
2935 let mut exif_payload: Option<&[u8]> = None;
2938
2939 while offset + 1 < bytes.len() {
2940 if bytes[offset] != 0xFF {
2941 return Err(TransformError::DecodeFailed(
2942 "jpeg file has an invalid marker prefix".to_string(),
2943 ));
2944 }
2945
2946 while offset < bytes.len() && bytes[offset] == 0xFF {
2947 offset += 1;
2948 }
2949
2950 if offset >= bytes.len() {
2951 break;
2952 }
2953
2954 let marker = bytes[offset];
2955 offset += 1;
2956
2957 if marker == 0xD9 || marker == 0xDA {
2958 break;
2959 }
2960
2961 if (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
2962 continue;
2963 }
2964
2965 if offset + 2 > bytes.len() {
2966 return Err(TransformError::DecodeFailed(
2967 "jpeg segment is truncated".to_string(),
2968 ));
2969 }
2970
2971 let segment_length = read_u16_be(&bytes[offset..offset + 2])? as usize;
2972 if segment_length < 2 || offset + segment_length > bytes.len() {
2973 return Err(TransformError::DecodeFailed(
2974 "jpeg segment length is invalid".to_string(),
2975 ));
2976 }
2977
2978 if marker == 0xE1
2979 && exif_payload.is_none()
2980 && let Some(payload) =
2981 bytes[offset + 2..offset + segment_length].strip_prefix(b"Exif\0\0".as_slice())
2982 {
2983 exif_payload = Some(payload);
2984 }
2985
2986 if is_jpeg_sof_marker(marker) {
2987 if segment_length < 7 {
2988 return Err(TransformError::DecodeFailed(
2989 "jpeg SOF segment is too short".to_string(),
2990 ));
2991 }
2992
2993 let height = read_u16_be(&bytes[offset + 3..offset + 5])? as u32;
2994 let width = read_u16_be(&bytes[offset + 5..offset + 7])? as u32;
2995
2996 return Ok(ArtifactMetadata {
2997 width: Some(width),
2998 height: Some(height),
2999 frame_count: 1,
3000 duration: None,
3001 has_alpha: Some(false),
3002 orientation: exif_payload.and_then(exif_orientation_from_payload),
3003 });
3004 }
3005
3006 offset += segment_length;
3007 }
3008
3009 Err(TransformError::DecodeFailed(
3010 "jpeg file is missing a SOF segment".to_string(),
3011 ))
3012}
3013
3014fn sniff_webp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
3015 let mut offset = 12;
3016
3017 while offset + 8 <= bytes.len() {
3018 let chunk_tag = &bytes[offset..offset + 4];
3019 let chunk_size = read_u32_le(&bytes[offset + 4..offset + 8])? as usize;
3020 let chunk_start = offset + 8;
3021 let chunk_end = chunk_start
3022 .checked_add(chunk_size)
3023 .ok_or_else(|| TransformError::DecodeFailed("webp chunk is too large".to_string()))?;
3024
3025 if chunk_end > bytes.len() {
3026 return Err(TransformError::DecodeFailed(
3027 "webp chunk exceeds file length".to_string(),
3028 ));
3029 }
3030
3031 let chunk_data = &bytes[chunk_start..chunk_end];
3032
3033 let mut metadata = match chunk_tag {
3034 b"VP8X" => sniff_webp_vp8x(chunk_data)?,
3035 b"VP8 " => sniff_webp_vp8(chunk_data)?,
3036 b"VP8L" => sniff_webp_vp8l(chunk_data)?,
3037 _ => {
3038 offset = chunk_end + (chunk_size % 2);
3039 continue;
3040 }
3041 };
3042
3043 metadata.orientation = exif_orientation(MediaType::Webp, bytes);
3047 if metadata.frame_count > 1 {
3048 metadata.frame_count = count_webp_frames(bytes).max(2);
3049 }
3050 return Ok(metadata);
3051 }
3052
3053 Err(TransformError::DecodeFailed(
3054 "webp file is missing an image chunk".to_string(),
3055 ))
3056}
3057
3058fn count_webp_frames(bytes: &[u8]) -> u32 {
3060 let mut frames = 0_u32;
3061 let mut offset = 12;
3062 while offset + 8 <= bytes.len() {
3063 let Ok(size) = read_u32_le(&bytes[offset + 4..offset + 8]) else {
3064 break;
3065 };
3066 if &bytes[offset..offset + 4] == b"ANMF" {
3067 frames = frames.saturating_add(1);
3068 }
3069 let size = size as usize;
3070 let Some(next) = offset
3071 .checked_add(8)
3072 .and_then(|next| next.checked_add(size))
3073 .and_then(|next| next.checked_add(size % 2))
3074 else {
3075 break;
3076 };
3077 offset = next;
3078 }
3079 frames
3080}
3081
3082fn sniff_webp_vp8x(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
3083 if bytes.len() < 10 {
3084 return Err(TransformError::DecodeFailed(
3085 "webp VP8X chunk is too short".to_string(),
3086 ));
3087 }
3088
3089 let flags = bytes[0];
3090 let width = read_u24_le(&bytes[4..7])? + 1;
3091 let height = read_u24_le(&bytes[7..10])? + 1;
3092 let has_alpha = Some(flags & VP8X_ALPHA_FLAG != 0);
3093 let frame_count = u32::from(flags & VP8X_ANIMATION_FLAG != 0) + 1;
3096
3097 Ok(ArtifactMetadata {
3098 width: Some(width),
3099 height: Some(height),
3100 frame_count,
3101 duration: None,
3102 has_alpha,
3103 orientation: None,
3104 })
3105}
3106
3107const VP8X_ALPHA_FLAG: u8 = 0b0001_0000;
3109const VP8X_ANIMATION_FLAG: u8 = 0b0000_0010;
3110
3111fn sniff_webp_vp8(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
3112 if bytes.len() < 10 {
3113 return Err(TransformError::DecodeFailed(
3114 "webp VP8 chunk is too short".to_string(),
3115 ));
3116 }
3117
3118 if bytes[3..6] != [0x9D, 0x01, 0x2A] {
3119 return Err(TransformError::DecodeFailed(
3120 "webp VP8 chunk has an invalid start code".to_string(),
3121 ));
3122 }
3123
3124 let width = (read_u16_le(&bytes[6..8])? & 0x3FFF) as u32;
3125 let height = (read_u16_le(&bytes[8..10])? & 0x3FFF) as u32;
3126
3127 Ok(ArtifactMetadata {
3128 width: Some(width),
3129 height: Some(height),
3130 frame_count: 1,
3131 duration: None,
3132 has_alpha: Some(false),
3133 orientation: None,
3134 })
3135}
3136
3137fn sniff_webp_vp8l(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
3138 if bytes.len() < 5 {
3139 return Err(TransformError::DecodeFailed(
3140 "webp VP8L chunk is too short".to_string(),
3141 ));
3142 }
3143
3144 if bytes[0] != 0x2F {
3145 return Err(TransformError::DecodeFailed(
3146 "webp VP8L chunk has an invalid signature".to_string(),
3147 ));
3148 }
3149
3150 let bits = read_u32_le(&bytes[1..5])?;
3153 let width = (bits & 0x3FFF) + 1;
3154 let height = ((bits >> 14) & 0x3FFF) + 1;
3155 let has_alpha = (bits >> 28) & 1 != 0;
3156
3157 Ok(ArtifactMetadata {
3158 width: Some(width),
3159 height: Some(height),
3160 frame_count: 1,
3161 duration: None,
3162 has_alpha: Some(has_alpha),
3163 orientation: None,
3164 })
3165}
3166
3167fn is_jpeg_sof_marker(marker: u8) -> bool {
3168 matches!(
3169 marker,
3170 0xC0 | 0xC1 | 0xC2 | 0xC3 | 0xC5 | 0xC6 | 0xC7 | 0xC9 | 0xCA | 0xCB | 0xCD | 0xCE | 0xCF
3171 )
3172}
3173
3174fn read_u16_be(bytes: &[u8]) -> Result<u16, TransformError> {
3175 let array: [u8; 2] = bytes
3176 .try_into()
3177 .map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
3178 Ok(u16::from_be_bytes(array))
3179}
3180
3181fn read_u16_le(bytes: &[u8]) -> Result<u16, TransformError> {
3182 let array: [u8; 2] = bytes
3183 .try_into()
3184 .map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
3185 Ok(u16::from_le_bytes(array))
3186}
3187
3188fn read_u24_le(bytes: &[u8]) -> Result<u32, TransformError> {
3189 if bytes.len() != 3 {
3190 return Err(TransformError::DecodeFailed("expected 3 bytes".to_string()));
3191 }
3192
3193 Ok(u32::from(bytes[0]) | (u32::from(bytes[1]) << 8) | (u32::from(bytes[2]) << 16))
3194}
3195
3196fn read_u32_be(bytes: &[u8]) -> Result<u32, TransformError> {
3197 let array: [u8; 4] = bytes
3198 .try_into()
3199 .map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
3200 Ok(u32::from_be_bytes(array))
3201}
3202
3203fn read_u32_le(bytes: &[u8]) -> Result<u32, TransformError> {
3204 let array: [u8; 4] = bytes
3205 .try_into()
3206 .map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
3207 Ok(u32::from_le_bytes(array))
3208}
3209
3210fn read_u64_be(bytes: &[u8]) -> Result<u64, TransformError> {
3211 let array: [u8; 8] = bytes
3212 .try_into()
3213 .map_err(|_| TransformError::DecodeFailed("expected 8 bytes".to_string()))?;
3214 Ok(u64::from_be_bytes(array))
3215}
3216
3217#[cfg(test)]
3218mod tests {
3219 #[test]
3223 fn the_input_pixel_caps_are_the_documented_numbers() {
3224 assert_eq!(super::MAX_DECODED_PIXELS, 100_000_000);
3225 assert_eq!(super::MAX_WATERMARK_PIXELS, 4_000_000);
3226 }
3227
3228 #[test]
3232 fn metadata_flag_resolution() {
3233 use super::resolve_metadata_flags;
3234
3235 let (strip, exif) = resolve_metadata_flags(None, None, None).unwrap();
3237 assert!(strip);
3238 assert!(!exif);
3239
3240 let (strip, exif) = resolve_metadata_flags(None, Some(true), None).unwrap();
3242 assert!(!strip);
3243 assert!(!exif);
3244
3245 let (strip, exif) = resolve_metadata_flags(None, None, Some(true)).unwrap();
3247 assert!(!strip);
3248 assert!(exif);
3249
3250 assert!(resolve_metadata_flags(None, Some(true), Some(true)).is_err());
3252 }
3253
3254 #[test]
3262 fn metadata_policy_resolution() {
3263 let options = TransformOptions::default();
3264 assert!(options.strip_metadata);
3265 assert_eq!(
3266 options.normalize(MediaType::Png).unwrap().metadata_policy,
3267 MetadataPolicy::StripAll
3268 );
3269
3270 let options = TransformOptions {
3271 strip_metadata: false,
3272 ..TransformOptions::default()
3273 };
3274 assert_eq!(
3275 options.normalize(MediaType::Png).unwrap().metadata_policy,
3276 MetadataPolicy::KeepAll
3277 );
3278
3279 let options = TransformOptions {
3280 strip_metadata: false,
3281 preserve_exif: true,
3282 ..TransformOptions::default()
3283 };
3284 assert_eq!(
3285 options.normalize(MediaType::Jpeg).unwrap().metadata_policy,
3286 MetadataPolicy::PreserveExif
3287 );
3288 }
3289
3290 #[cfg(any(feature = "server", feature = "wasm"))]
3291 use super::single_line;
3292 use super::{
3293 Artifact, ArtifactMetadata, Dimensions, Fit, MediaType, MetadataPolicy, OptimizeMode,
3294 Position, QualityMetric, RawArtifact, Rgba8, Rotation, TargetQuality, TransformError,
3295 TransformOptions, TransformRequest, exif_orientation, sniff_artifact,
3296 validate_height_value, validate_quality_value, validate_watermark_opacity_value,
3297 validate_width_value,
3298 };
3299 #[cfg(feature = "avif")]
3300 use image::codecs::avif::AvifEncoder;
3301 use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
3302 use rstest::rstest;
3303
3304 #[test]
3310 fn a_named_value_has_one_spelling() {
3311 use std::str::FromStr;
3312
3313 assert!(MediaType::from_str("jpeg").is_ok());
3314 assert!(MediaType::from_str("JPEG").is_err());
3315 assert!(Fit::from_str("cover").is_ok());
3316 assert!(Fit::from_str("COVER").is_err());
3317 assert!(Position::from_str("center").is_ok());
3318 assert!(Position::from_str("CENTER").is_err());
3319 assert!(OptimizeMode::from_str("lossless").is_ok());
3320 assert!(OptimizeMode::from_str("LOSSLESS").is_err());
3321 assert!(TargetQuality::from_str("ssim:0.98").is_ok());
3322 assert_eq!(
3323 TargetQuality::from_str("SSIM:0.98"),
3324 Err("unsupported target quality metric `SSIM`".to_string())
3325 );
3326 assert_eq!(
3327 TargetQuality::from_str("Psnr:42"),
3328 Err("unsupported target quality metric `Psnr`".to_string())
3329 );
3330 }
3331
3332 #[cfg(any(feature = "server", feature = "wasm"))]
3334 #[test]
3335 fn single_line_leaves_a_line_alone() {
3336 assert_eq!(
3337 single_line("quality must be between 1 and 100"),
3338 "quality must be between 1 and 100"
3339 );
3340 assert_eq!(single_line(""), "");
3341 }
3342
3343 #[cfg(any(feature = "server", feature = "wasm"))]
3345 #[test]
3346 fn single_line_folds_a_message_that_leaves_its_line() {
3347 assert_eq!(
3348 single_line("Format error decoding Jpeg: Not enough bytes\n"),
3349 "Format error decoding Jpeg: Not enough bytes"
3350 );
3351 assert_eq!(single_line("first\nsecond"), "first second");
3352 assert_eq!(single_line("first\r\n\tsecond"), "first second");
3353 assert_eq!(single_line(" padded "), "padded");
3354 }
3355
3356 fn jpeg_artifact() -> Artifact {
3357 Artifact::new(vec![1, 2, 3], MediaType::Jpeg, ArtifactMetadata::default())
3358 }
3359
3360 fn png_ihdr_bytes(width: u32, height: u32, color_type: u8) -> Vec<u8> {
3363 let mut bytes = Vec::new();
3364 bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
3365 bytes.extend_from_slice(&13_u32.to_be_bytes());
3366 bytes.extend_from_slice(b"IHDR");
3367 bytes.extend_from_slice(&width.to_be_bytes());
3368 bytes.extend_from_slice(&height.to_be_bytes());
3369 bytes.push(8);
3370 bytes.push(color_type);
3371 bytes.push(0);
3372 bytes.push(0);
3373 bytes.push(0);
3374 bytes.extend_from_slice(&0_u32.to_be_bytes());
3375 bytes
3376 }
3377
3378 fn png_bytes_with_chunks(color_type: u8, chunks: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
3383 let mut bytes = png_ihdr_bytes(8, 8, color_type);
3384 for (chunk_type, data) in chunks {
3385 bytes.extend_from_slice(&(data.len() as u32).to_be_bytes());
3386 bytes.extend_from_slice(*chunk_type);
3387 bytes.extend_from_slice(data);
3388 bytes.extend_from_slice(&0_u32.to_be_bytes());
3389 }
3390 bytes.extend_from_slice(&0_u32.to_be_bytes());
3391 bytes.extend_from_slice(b"IEND");
3392 bytes.extend_from_slice(&0_u32.to_be_bytes());
3393 bytes
3394 }
3395
3396 fn jpeg_bytes(width: u16, height: u16) -> Vec<u8> {
3397 let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
3398 bytes.extend_from_slice(&[0; 14]);
3399 bytes.extend_from_slice(&[
3400 0xFF,
3401 0xC0,
3402 0x00,
3403 0x11,
3404 0x08,
3405 (height >> 8) as u8,
3406 height as u8,
3407 (width >> 8) as u8,
3408 width as u8,
3409 0x03,
3410 0x01,
3411 0x11,
3412 0x00,
3413 0x02,
3414 0x11,
3415 0x00,
3416 0x03,
3417 0x11,
3418 0x00,
3419 ]);
3420 bytes.extend_from_slice(&[0xFF, 0xD9]);
3421 bytes
3422 }
3423
3424 fn gif_bytes(
3429 version: &[u8; 3],
3430 width: u16,
3431 height: u16,
3432 frames: usize,
3433 transparent: bool,
3434 ) -> Vec<u8> {
3435 let mut bytes = Vec::new();
3436 bytes.extend_from_slice(b"GIF");
3437 bytes.extend_from_slice(version);
3438 bytes.extend_from_slice(&width.to_le_bytes());
3439 bytes.extend_from_slice(&height.to_le_bytes());
3440 bytes.push(0b1000_0000);
3442 bytes.push(0); bytes.push(0); bytes.extend_from_slice(&[0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF]);
3445
3446 for _ in 0..frames {
3447 if transparent {
3448 bytes.extend_from_slice(&[0x21, 0xF9, 0x04, 0b0000_0001, 0x00, 0x00, 0x00, 0x00]);
3450 }
3451 bytes.push(0x2C);
3453 bytes.extend_from_slice(&0u16.to_le_bytes());
3454 bytes.extend_from_slice(&0u16.to_le_bytes());
3455 bytes.extend_from_slice(&width.to_le_bytes());
3456 bytes.extend_from_slice(&height.to_le_bytes());
3457 bytes.push(0);
3458 bytes.push(0x02);
3460 bytes.extend_from_slice(&[0x02, 0x44, 0x01, 0x00]);
3461 }
3462
3463 bytes.push(0x3B);
3464 bytes
3465 }
3466
3467 fn webp_vp8x_bytes(width: u32, height: u32, flags: u8) -> Vec<u8> {
3468 let width_minus_one = width - 1;
3469 let height_minus_one = height - 1;
3470 let mut bytes = Vec::new();
3471 bytes.extend_from_slice(b"RIFF");
3472 bytes.extend_from_slice(&30_u32.to_le_bytes());
3473 bytes.extend_from_slice(b"WEBP");
3474 bytes.extend_from_slice(b"VP8X");
3475 bytes.extend_from_slice(&10_u32.to_le_bytes());
3476 bytes.push(flags);
3477 bytes.extend_from_slice(&[0, 0, 0]);
3478 bytes.extend_from_slice(&[
3479 (width_minus_one & 0xFF) as u8,
3480 ((width_minus_one >> 8) & 0xFF) as u8,
3481 ((width_minus_one >> 16) & 0xFF) as u8,
3482 ]);
3483 bytes.extend_from_slice(&[
3484 (height_minus_one & 0xFF) as u8,
3485 ((height_minus_one >> 8) & 0xFF) as u8,
3486 ((height_minus_one >> 16) & 0xFF) as u8,
3487 ]);
3488 bytes
3489 }
3490
3491 fn webp_vp8l_bytes(width: u32, height: u32) -> Vec<u8> {
3492 webp_vp8l_bytes_with_alpha(width, height, false)
3493 }
3494
3495 fn webp_vp8l_bytes_with_alpha(width: u32, height: u32, alpha_is_used: bool) -> Vec<u8> {
3496 let packed = (width - 1) | ((height - 1) << 14) | (u32::from(alpha_is_used) << 28);
3497 let mut bytes = Vec::new();
3498 bytes.extend_from_slice(b"RIFF");
3499 bytes.extend_from_slice(&17_u32.to_le_bytes());
3500 bytes.extend_from_slice(b"WEBP");
3501 bytes.extend_from_slice(b"VP8L");
3502 bytes.extend_from_slice(&5_u32.to_le_bytes());
3503 bytes.push(0x2F);
3504 bytes.extend_from_slice(&packed.to_le_bytes());
3505 bytes.push(0);
3506 bytes
3507 }
3508
3509 fn avif_bytes() -> Vec<u8> {
3510 let mut bytes = Vec::new();
3511 bytes.extend_from_slice(&24_u32.to_be_bytes());
3512 bytes.extend_from_slice(b"ftyp");
3513 bytes.extend_from_slice(b"avif");
3514 bytes.extend_from_slice(&0_u32.to_be_bytes());
3515 bytes.extend_from_slice(b"mif1");
3516 bytes.extend_from_slice(b"avif");
3517 bytes
3518 }
3519
3520 #[cfg(feature = "avif")]
3521 fn encoded_avif_bytes(width: u32, height: u32, fill: Rgba<u8>) -> Vec<u8> {
3522 let image = RgbaImage::from_pixel(width, height, fill);
3523 let mut bytes = Vec::new();
3524 AvifEncoder::new(&mut bytes)
3525 .write_image(&image, width, height, ColorType::Rgba8.into())
3526 .expect("encode avif");
3527 bytes
3528 }
3529
3530 #[test]
3531 fn default_transform_options_match_documented_defaults() {
3532 let options = TransformOptions::default();
3533
3534 assert_eq!(options.width, None);
3535 assert_eq!(options.height, None);
3536 assert_eq!(options.fit, None);
3537 assert_eq!(options.position, None);
3538 assert_eq!(options.format, None);
3539 assert_eq!(options.quality, None);
3540 assert_eq!(options.rotate, Rotation::DEG_0);
3541 assert!(options.auto_orient);
3542 assert!(options.strip_metadata);
3543 assert!(!options.preserve_exif);
3544 }
3545
3546 #[test]
3547 fn media_type_helpers_report_expected_values() {
3548 assert_eq!(MediaType::Jpeg.as_name(), "jpeg");
3549 assert_eq!(MediaType::Jpeg.as_mime(), "image/jpeg");
3550 assert!(MediaType::Webp.is_lossy());
3551 assert!(!MediaType::Png.is_lossy());
3552 }
3553
3554 #[test]
3555 fn media_type_parsing_accepts_documented_names() {
3556 assert_eq!("jpeg".parse::<MediaType>(), Ok(MediaType::Jpeg));
3557 assert_eq!("jpg".parse::<MediaType>(), Ok(MediaType::Jpeg));
3558 assert_eq!("png".parse::<MediaType>(), Ok(MediaType::Png));
3559 assert_eq!("gif".parse::<MediaType>(), Ok(MediaType::Gif));
3562 assert!("heic".parse::<MediaType>().is_err());
3563 }
3564
3565 #[test]
3566 fn fit_position_rotation_and_color_parsing_work() {
3567 assert_eq!("cover".parse::<Fit>(), Ok(Fit::Cover));
3568 assert_eq!(
3569 "bottom-right".parse::<Position>(),
3570 Ok(Position::BottomRight)
3571 );
3572 assert_eq!("270".parse::<Rotation>(), Ok(Rotation::DEG_270));
3573 assert_eq!(
3574 Rgba8::from_hex("AABBCCDD"),
3575 Ok(Rgba8 {
3576 r: 0xAA,
3577 g: 0xBB,
3578 b: 0xCC,
3579 a: 0xDD
3580 })
3581 );
3582 assert!(Rgba8::from_hex("AABB").is_err());
3583
3584 assert!(Rgba8::from_hex("\u{00e9}\u{00e9}\u{00e9}").is_err());
3586 assert!(Rgba8::from_hex("\u{1f600}\u{1f600}").is_err());
3587 }
3588
3589 #[test]
3590 fn normalize_defaults_fit_and_position_for_bounded_resize() {
3591 let normalized = TransformOptions {
3592 width: Some(1200),
3593 height: Some(630),
3594 ..TransformOptions::default()
3595 }
3596 .normalize(MediaType::Jpeg)
3597 .expect("normalize bounded resize");
3598
3599 assert_eq!(normalized.fit, Some(Fit::Contain));
3600 assert_eq!(normalized.position, Position::Center);
3601 assert_eq!(normalized.format, MediaType::Jpeg);
3602 assert_eq!(normalized.metadata_policy, MetadataPolicy::StripAll);
3603 }
3604
3605 #[test]
3606 fn normalize_uses_requested_fit_and_output_format() {
3607 let normalized = TransformOptions {
3608 width: Some(320),
3609 height: Some(320),
3610 fit: Some(Fit::Cover),
3611 position: Some(Position::BottomRight),
3612 format: Some(MediaType::Webp),
3613 quality: Some(70),
3614 strip_metadata: false,
3615 preserve_exif: true,
3616 ..TransformOptions::default()
3617 }
3618 .normalize(MediaType::Jpeg)
3619 .expect("normalize explicit values");
3620
3621 assert_eq!(normalized.fit, Some(Fit::Cover));
3622 assert_eq!(normalized.position, Position::BottomRight);
3623 assert_eq!(normalized.format, MediaType::Webp);
3624 assert_eq!(normalized.quality, Some(70));
3625 assert_eq!(normalized.metadata_policy, MetadataPolicy::PreserveExif);
3626 }
3627
3628 #[test]
3629 fn normalize_can_keep_all_metadata() {
3630 let normalized = TransformOptions {
3631 strip_metadata: false,
3632 ..TransformOptions::default()
3633 }
3634 .normalize(MediaType::Jpeg)
3635 .expect("normalize keep metadata");
3636
3637 assert_eq!(normalized.metadata_policy, MetadataPolicy::KeepAll);
3638 }
3639
3640 #[test]
3641 fn normalize_lossy_optimize_preserves_icc_by_default() {
3642 let normalized = TransformOptions {
3643 optimize: OptimizeMode::Lossy,
3644 format: Some(MediaType::Jpeg),
3645 ..TransformOptions::default()
3646 }
3647 .normalize(MediaType::Jpeg)
3648 .expect("normalize lossy optimize metadata policy");
3649
3650 assert_eq!(normalized.metadata_policy, MetadataPolicy::PreserveIcc);
3651 }
3652
3653 #[rstest]
3657 #[case::none(OptimizeMode::None, MetadataPolicy::StripAll)]
3658 #[case::auto(OptimizeMode::Auto, MetadataPolicy::PreserveIcc)]
3659 #[case::lossless(OptimizeMode::Lossless, MetadataPolicy::PreserveIcc)]
3660 #[case::lossy(OptimizeMode::Lossy, MetadataPolicy::PreserveIcc)]
3661 fn an_optimization_keeps_the_profile_a_plain_encode_strips(
3662 #[case] optimize: OptimizeMode,
3663 #[case] expected: MetadataPolicy,
3664 ) {
3665 let normalized = TransformOptions {
3666 optimize,
3667 strip_metadata: true,
3668 format: Some(MediaType::Jpeg),
3669 ..TransformOptions::default()
3670 }
3671 .normalize(MediaType::Jpeg)
3672 .expect("normalize the metadata policy");
3673
3674 assert_eq!(normalized.metadata_policy, expected);
3675 }
3676
3677 #[test]
3682 fn an_optimization_strips_for_a_format_that_carries_no_profile() {
3683 let normalized = TransformOptions {
3684 optimize: OptimizeMode::Auto,
3685 strip_metadata: true,
3686 format: Some(MediaType::Avif),
3687 ..TransformOptions::default()
3688 }
3689 .normalize(MediaType::Jpeg)
3690 .expect("normalize the metadata policy");
3691
3692 assert_eq!(normalized.metadata_policy, MetadataPolicy::StripAll);
3693 }
3694
3695 #[test]
3696 fn normalize_lossy_optimize_preserves_icc_for_webp_output() {
3697 let normalized = TransformOptions {
3698 optimize: OptimizeMode::Lossy,
3699 format: Some(MediaType::Webp),
3700 strip_metadata: true,
3701 ..TransformOptions::default()
3702 }
3703 .normalize(MediaType::Jpeg)
3704 .expect("normalize lossy webp metadata policy");
3705
3706 assert_eq!(normalized.metadata_policy, MetadataPolicy::PreserveIcc);
3707 }
3708
3709 #[test]
3713 fn normalize_lossy_optimize_strips_all_for_a_format_without_icc_support() {
3714 let normalized = TransformOptions {
3715 optimize: OptimizeMode::Lossy,
3716 format: Some(MediaType::Avif),
3717 strip_metadata: true,
3718 ..TransformOptions::default()
3719 }
3720 .normalize(MediaType::Jpeg)
3721 .expect("normalize lossy avif metadata policy");
3722
3723 assert_eq!(normalized.metadata_policy, MetadataPolicy::StripAll);
3724 }
3725
3726 #[test]
3727 fn normalize_keeps_fit_none_when_resize_is_not_bounded() {
3728 let normalized = TransformOptions {
3729 width: Some(500),
3730 ..TransformOptions::default()
3731 }
3732 .normalize(MediaType::Jpeg)
3733 .expect("normalize unbounded resize");
3734
3735 assert_eq!(normalized.fit, None);
3736 assert_eq!(normalized.position, Position::Center);
3737 }
3738
3739 #[test]
3740 fn normalize_rejects_zero_dimensions() {
3741 let err = TransformOptions {
3742 width: Some(0),
3743 ..TransformOptions::default()
3744 }
3745 .normalize(MediaType::Jpeg)
3746 .expect_err("zero width should fail");
3747
3748 assert_eq!(
3749 err,
3750 TransformError::InvalidOptions("width must be greater than zero".to_string())
3751 );
3752 }
3753
3754 #[test]
3755 fn normalize_rejects_fit_without_both_dimensions() {
3756 let err = TransformOptions {
3757 width: Some(300),
3758 fit: Some(Fit::Contain),
3759 ..TransformOptions::default()
3760 }
3761 .normalize(MediaType::Jpeg)
3762 .expect_err("fit without bounded resize should fail");
3763
3764 assert_eq!(
3765 err,
3766 TransformError::InvalidOptions("fit requires both width and height".to_string())
3767 );
3768 }
3769
3770 #[rstest]
3775 #[case(
3776 TransformOptions { width: Some(300), fit: Some(Fit::Contain), ..TransformOptions::default() },
3777 "fit requires both width and height"
3778 )]
3779 #[case(
3780 TransformOptions { height: Some(300), position: Some(Position::Top), ..TransformOptions::default() },
3781 "position requires both width and height"
3782 )]
3783 #[case(
3784 TransformOptions { without_enlargement: true, ..TransformOptions::default() },
3785 "withoutEnlargement requires width or height"
3786 )]
3787 #[case(
3788 TransformOptions { width: Some(0), ..TransformOptions::default() },
3789 "width must be greater than zero"
3790 )]
3791 #[case(
3792 TransformOptions { height: Some(0), ..TransformOptions::default() },
3793 "height must be greater than zero"
3794 )]
3795 #[case(
3796 TransformOptions { quality: Some(101), format: Some(MediaType::Jpeg), ..TransformOptions::default() },
3797 "quality must be between 1 and 100"
3798 )]
3799 #[case(
3800 TransformOptions { blur: Some(200.0), ..TransformOptions::default() },
3801 "blur sigma must be between 0.1 and 100.0"
3802 )]
3803 #[case(
3804 TransformOptions { sharpen: Some(500.0), ..TransformOptions::default() },
3805 "sharpen sigma must be between 0.1 and 100.0"
3806 )]
3807 #[case(
3808 TransformOptions {
3809 crop: Some(crate::CropRegion { x: 0, y: 0, width: 0, height: 0 }),
3810 ..TransformOptions::default()
3811 },
3812 "crop width and height must be greater than zero"
3813 )]
3814 fn the_input_independent_rules_answer_the_same_through_either_door(
3815 #[case] options: TransformOptions,
3816 #[case] message: &str,
3817 ) {
3818 let expected = TransformError::InvalidOptions(message.to_string());
3819
3820 assert_eq!(
3821 options
3822 .validate_without_input()
3823 .expect_err("the options contradict each other whatever the input is"),
3824 expected
3825 );
3826 assert_eq!(
3827 options
3828 .normalize(MediaType::Jpeg)
3829 .expect_err("normalize runs the same list first"),
3830 expected
3831 );
3832 }
3833
3834 #[test]
3835 fn normalize_rejects_position_without_both_dimensions() {
3836 let err = TransformOptions {
3837 height: Some(300),
3838 position: Some(Position::Top),
3839 ..TransformOptions::default()
3840 }
3841 .normalize(MediaType::Jpeg)
3842 .expect_err("position without bounded resize should fail");
3843
3844 assert_eq!(
3845 err,
3846 TransformError::InvalidOptions("position requires both width and height".to_string())
3847 );
3848 }
3849
3850 #[test]
3851 fn normalize_rejects_quality_for_lossless_output() {
3852 let err = TransformOptions {
3853 format: Some(MediaType::Png),
3854 quality: Some(80),
3855 ..TransformOptions::default()
3856 }
3857 .normalize(MediaType::Jpeg)
3858 .expect_err("quality for png should fail");
3859
3860 assert_eq!(
3861 err,
3862 TransformError::InvalidOptions("quality requires a lossy output format".to_string())
3863 );
3864 }
3865
3866 #[test]
3867 fn normalize_rejects_zero_quality() {
3868 let err = TransformOptions {
3869 quality: Some(0),
3870 ..TransformOptions::default()
3871 }
3872 .normalize(MediaType::Jpeg)
3873 .expect_err("zero quality should fail");
3874
3875 assert_eq!(
3876 err,
3877 TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
3878 );
3879 }
3880
3881 #[test]
3882 fn normalize_rejects_quality_above_one_hundred() {
3883 let err = TransformOptions {
3884 quality: Some(101),
3885 ..TransformOptions::default()
3886 }
3887 .normalize(MediaType::Jpeg)
3888 .expect_err("quality above one hundred should fail");
3889
3890 assert_eq!(
3891 err,
3892 TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
3893 );
3894 }
3895
3896 #[test]
3897 fn normalize_rejects_preserve_exif_when_metadata_is_stripped() {
3898 let err = TransformOptions {
3899 preserve_exif: true,
3900 ..TransformOptions::default()
3901 }
3902 .normalize(MediaType::Jpeg)
3903 .expect_err("preserve_exif should require metadata retention");
3904
3905 assert_eq!(
3906 err,
3907 TransformError::InvalidOptions(
3908 "preserveExif requires stripMetadata to be false".to_string()
3909 )
3910 );
3911 }
3912
3913 #[test]
3914 fn normalize_validates_optimize_and_target_quality_matrix() {
3915 struct Case {
3916 name: &'static str,
3917 input_media_type: MediaType,
3918 options: TransformOptions,
3919 expected_error: Option<&'static str>,
3920 }
3921
3922 let cases = [
3923 Case {
3924 name: "target quality requires optimize auto or lossy",
3925 input_media_type: MediaType::Jpeg,
3926 options: TransformOptions {
3927 format: Some(MediaType::Jpeg),
3928 target_quality: Some(TargetQuality {
3929 metric: QualityMetric::Ssim,
3930 value: 0.98,
3931 }),
3932 ..TransformOptions::default()
3933 },
3934 expected_error: Some("targetQuality requires optimize=auto or optimize=lossy"),
3935 },
3936 Case {
3937 name: "target quality not allowed with lossless optimize",
3938 input_media_type: MediaType::Webp,
3939 options: TransformOptions {
3940 format: Some(MediaType::Webp),
3941 optimize: OptimizeMode::Lossless,
3942 target_quality: Some(TargetQuality {
3943 metric: QualityMetric::Ssim,
3944 value: 0.98,
3945 }),
3946 ..TransformOptions::default()
3947 },
3948 expected_error: Some("targetQuality requires optimize=auto or optimize=lossy"),
3949 },
3950 Case {
3951 name: "target quality requires lossy optimizable output",
3952 input_media_type: MediaType::Png,
3953 options: TransformOptions {
3954 format: Some(MediaType::Png),
3955 optimize: OptimizeMode::Auto,
3956 target_quality: Some(TargetQuality {
3957 metric: QualityMetric::Ssim,
3958 value: 0.98,
3959 }),
3960 ..TransformOptions::default()
3961 },
3962 expected_error: Some("targetQuality requires jpeg, webp, or avif output"),
3963 },
3964 Case {
3965 name: "quality cannot combine with lossless optimize",
3966 input_media_type: MediaType::Jpeg,
3967 options: TransformOptions {
3968 format: Some(MediaType::Jpeg),
3969 optimize: OptimizeMode::Lossless,
3970 quality: Some(80),
3971 ..TransformOptions::default()
3972 },
3973 expected_error: Some("quality cannot be combined with optimize=lossless"),
3974 },
3975 Case {
3976 name: "lossy optimize requires lossy capable format",
3977 input_media_type: MediaType::Png,
3978 options: TransformOptions {
3979 format: Some(MediaType::Png),
3980 optimize: OptimizeMode::Lossy,
3981 ..TransformOptions::default()
3982 },
3983 expected_error: Some(
3984 "lossy optimization requires jpeg, webp, or avif output, got png",
3985 ),
3986 },
3987 Case {
3988 name: "optimize unsupported for svg output",
3989 input_media_type: MediaType::Svg,
3990 options: TransformOptions {
3991 format: Some(MediaType::Svg),
3992 optimize: OptimizeMode::Auto,
3993 ..TransformOptions::default()
3994 },
3995 expected_error: Some("optimization is not supported for svg output"),
3996 },
3997 Case {
3998 name: "preserve exif unsupported for svg output",
3999 input_media_type: MediaType::Svg,
4000 options: TransformOptions {
4001 format: Some(MediaType::Svg),
4002 preserve_exif: true,
4003 strip_metadata: false,
4004 ..TransformOptions::default()
4005 },
4006 expected_error: Some("preserveExif is not supported with SVG output"),
4007 },
4008 Case {
4012 name: "width unsupported for svg output",
4013 input_media_type: MediaType::Svg,
4014 options: TransformOptions {
4015 format: Some(MediaType::Svg),
4016 width: Some(100),
4017 ..TransformOptions::default()
4018 },
4019 expected_error: Some(
4020 "width is not supported with SVG output; choose a raster output format such as png",
4021 ),
4022 },
4023 Case {
4024 name: "height unsupported for svg output",
4025 input_media_type: MediaType::Svg,
4026 options: TransformOptions {
4027 format: Some(MediaType::Svg),
4028 height: Some(100),
4029 ..TransformOptions::default()
4030 },
4031 expected_error: Some(
4032 "height is not supported with SVG output; choose a raster output format such as png",
4033 ),
4034 },
4035 Case {
4036 name: "rotate unsupported for svg output",
4037 input_media_type: MediaType::Svg,
4038 options: TransformOptions {
4039 format: Some(MediaType::Svg),
4040 rotate: Rotation::DEG_90,
4041 ..TransformOptions::default()
4042 },
4043 expected_error: Some(
4044 "rotate is not supported with SVG output; choose a raster output format such as png",
4045 ),
4046 },
4047 Case {
4048 name: "grayscale unsupported for svg output",
4049 input_media_type: MediaType::Svg,
4050 options: TransformOptions {
4051 format: Some(MediaType::Svg),
4052 grayscale: true,
4053 ..TransformOptions::default()
4054 },
4055 expected_error: Some(
4056 "grayscale is not supported with SVG output; choose a raster output format such as png",
4057 ),
4058 },
4059 Case {
4060 name: "background unsupported for svg output",
4061 input_media_type: MediaType::Svg,
4062 options: TransformOptions {
4063 format: Some(MediaType::Svg),
4064 background: Some(Rgba8 {
4065 r: 255,
4066 g: 0,
4067 b: 0,
4068 a: 255,
4069 }),
4070 ..TransformOptions::default()
4071 },
4072 expected_error: Some(
4073 "background is not supported with SVG output; choose a raster output format such as png",
4074 ),
4075 },
4076 Case {
4077 name: "svg passthrough with no transform options is accepted",
4078 input_media_type: MediaType::Svg,
4079 options: TransformOptions {
4080 format: Some(MediaType::Svg),
4081 rotate: Rotation::DEG_0,
4082 ..TransformOptions::default()
4083 },
4084 expected_error: None,
4085 },
4086 Case {
4087 name: "svg input rasterized to png accepts the same options",
4088 input_media_type: MediaType::Svg,
4089 options: TransformOptions {
4090 format: Some(MediaType::Png),
4091 width: Some(100),
4092 height: Some(100),
4093 rotate: Rotation::DEG_90,
4094 grayscale: true,
4095 ..TransformOptions::default()
4096 },
4097 expected_error: None,
4098 },
4099 Case {
4100 name: "auto optimize accepts lossy target quality",
4101 input_media_type: MediaType::Jpeg,
4102 options: TransformOptions {
4103 format: Some(MediaType::Jpeg),
4104 optimize: OptimizeMode::Auto,
4105 target_quality: Some(TargetQuality {
4106 metric: QualityMetric::Ssim,
4107 value: 0.98,
4108 }),
4109 ..TransformOptions::default()
4110 },
4111 expected_error: None,
4112 },
4113 Case {
4114 name: "lossless optimize accepts png without quality",
4115 input_media_type: MediaType::Png,
4116 options: TransformOptions {
4117 format: Some(MediaType::Png),
4118 optimize: OptimizeMode::Lossless,
4119 ..TransformOptions::default()
4120 },
4121 expected_error: None,
4122 },
4123 ];
4124
4125 for case in cases {
4126 let result = case.options.normalize(case.input_media_type);
4127 match case.expected_error {
4128 Some(message) => {
4129 let error = result.expect_err(case.name);
4130 assert_eq!(
4131 error,
4132 TransformError::InvalidOptions(message.to_string()),
4133 "{}",
4134 case.name
4135 );
4136 }
4137 None => {
4138 result.expect(case.name);
4139 }
4140 }
4141 }
4142 }
4143
4144 #[test]
4145 fn transform_request_normalize_uses_input_media_type_as_default_output() {
4146 let request = TransformRequest::new(jpeg_artifact(), TransformOptions::default());
4147 let normalized = request.normalize().expect("normalize request");
4148
4149 assert_eq!(normalized.input.media_type, MediaType::Jpeg);
4150 assert_eq!(normalized.options.format, MediaType::Jpeg);
4151 assert_eq!(normalized.options.metadata_policy, MetadataPolicy::StripAll);
4152 }
4153
4154 #[test]
4155 fn sniff_artifact_detects_png_dimensions_and_alpha() {
4156 let artifact =
4157 sniff_artifact(RawArtifact::new(png_ihdr_bytes(64, 32, 6), None)).expect("sniff png");
4158
4159 assert_eq!(artifact.media_type, MediaType::Png);
4160 assert_eq!(artifact.metadata.width, Some(64));
4161 assert_eq!(artifact.metadata.height, Some(32));
4162 assert_eq!(artifact.metadata.has_alpha, Some(true));
4163 }
4164
4165 #[test]
4166 fn sniff_artifact_detects_jpeg_dimensions() {
4167 let artifact =
4168 sniff_artifact(RawArtifact::new(jpeg_bytes(320, 240), None)).expect("sniff jpeg");
4169
4170 assert_eq!(artifact.media_type, MediaType::Jpeg);
4171 assert_eq!(artifact.metadata.width, Some(320));
4172 assert_eq!(artifact.metadata.height, Some(240));
4173 assert_eq!(artifact.metadata.has_alpha, Some(false));
4174 }
4175
4176 #[test]
4177 fn normalize_defaults_gif_input_to_png_output() {
4178 let options = TransformOptions::default()
4180 .normalize(MediaType::Gif)
4181 .expect("gif input should normalize");
4182
4183 assert_eq!(options.format, MediaType::Png);
4184 }
4185
4186 #[test]
4187 fn normalize_keeps_an_explicit_format_for_gif_input() {
4188 let options = TransformOptions {
4189 format: Some(MediaType::Webp),
4190 ..TransformOptions::default()
4191 }
4192 .normalize(MediaType::Gif)
4193 .expect("gif input with an explicit format should normalize");
4194
4195 assert_eq!(options.format, MediaType::Webp);
4196 }
4197
4198 #[test]
4199 fn gif_is_not_encodable() {
4200 assert!(!MediaType::Gif.is_encodable());
4201 for media_type in [
4202 MediaType::Jpeg,
4203 MediaType::Png,
4204 MediaType::Webp,
4205 MediaType::Avif,
4206 MediaType::Svg,
4207 MediaType::Bmp,
4208 MediaType::Tiff,
4209 ] {
4210 assert!(
4211 media_type.is_encodable(),
4212 "{} should be encodable",
4213 media_type.as_name()
4214 );
4215 }
4216 }
4217
4218 #[test]
4219 fn sniff_artifact_detects_static_gif87a() {
4220 let artifact = sniff_artifact(RawArtifact::new(
4221 gif_bytes(b"87a", 640, 480, 1, false),
4222 None,
4223 ))
4224 .expect("sniff gif87a");
4225
4226 assert_eq!(artifact.media_type, MediaType::Gif);
4227 assert_eq!(artifact.metadata.width, Some(640));
4228 assert_eq!(artifact.metadata.height, Some(480));
4229 assert_eq!(artifact.metadata.frame_count, 1);
4230 assert_eq!(artifact.metadata.has_alpha, Some(false));
4231 }
4232
4233 #[test]
4234 fn sniff_artifact_detects_gif89a_transparency() {
4235 let artifact = sniff_artifact(RawArtifact::new(gif_bytes(b"89a", 4, 4, 1, true), None))
4236 .expect("sniff transparent gif");
4237
4238 assert_eq!(artifact.media_type, MediaType::Gif);
4239 assert_eq!(
4240 artifact.metadata.has_alpha,
4241 Some(true),
4242 "a Graphic Control Extension with the transparent-color flag means alpha"
4243 );
4244 }
4245
4246 #[test]
4247 fn sniff_artifact_counts_gif_frames() {
4248 let artifact = sniff_artifact(RawArtifact::new(gif_bytes(b"89a", 8, 8, 5, true), None))
4252 .expect("sniff animated gif");
4253
4254 assert_eq!(artifact.metadata.frame_count, 5);
4255 }
4256
4257 #[test]
4264 fn a_rejected_color_is_told_what_a_color_looks_like() {
4265 for value in [
4266 "#ffffff", "fff", "white", "0xffffff", "FFFFFFF", "", "gggggg",
4267 ] {
4268 let message = Rgba8::from_hex(value).expect_err("not a color");
4269 assert!(
4270 message.contains("six or eight") && message.contains("hexadecimal"),
4271 "{value:?} was not told the digit count: {message}"
4272 );
4273 assert!(
4274 message.contains('#'),
4275 "{value:?} was not told that no `#` is used: {message}"
4276 );
4277 }
4278
4279 for value in ["ffffff", "FFFFFF", "ffffffaa", "000000"] {
4280 assert!(
4281 Rgba8::from_hex(value).is_ok(),
4282 "{value:?} should be a color"
4283 );
4284 }
4285 }
4286
4287 #[test]
4288 fn sniff_artifact_detects_an_animated_avif() {
4289 let mut bytes = Vec::new();
4293 bytes.extend_from_slice(&24_u32.to_be_bytes());
4294 bytes.extend_from_slice(b"ftyp");
4295 bytes.extend_from_slice(b"avis");
4296 bytes.extend_from_slice(&0_u32.to_be_bytes());
4297 bytes.extend_from_slice(b"avis");
4298 bytes.extend_from_slice(b"avif");
4299 let artifact =
4300 sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff an animated avif");
4301
4302 assert!(
4303 artifact.metadata.frame_count > 1,
4304 "an animated avif reported {} frames",
4305 artifact.metadata.frame_count
4306 );
4307 }
4308
4309 #[test]
4310 fn sniff_artifact_counts_the_frames_of_an_animated_avif() {
4311 fn mp4_box(box_type: &[u8; 4], payload: &[u8]) -> Vec<u8> {
4314 let mut out = ((payload.len() + 8) as u32).to_be_bytes().to_vec();
4315 out.extend_from_slice(box_type);
4316 out.extend_from_slice(payload);
4317 out
4318 }
4319
4320 let mut stsz = vec![0_u8; 4];
4321 stsz.extend_from_slice(&0_u32.to_be_bytes());
4322 stsz.extend_from_slice(&7_u32.to_be_bytes());
4323 let stbl = mp4_box(b"stbl", &mp4_box(b"stsz", &stsz));
4324 let minf = mp4_box(b"minf", &stbl);
4325 let mdia = mp4_box(b"mdia", &minf);
4326 let trak = mp4_box(b"trak", &mdia);
4327 let moov = mp4_box(b"moov", &trak);
4328
4329 let mut bytes = Vec::new();
4330 bytes.extend_from_slice(&24_u32.to_be_bytes());
4331 bytes.extend_from_slice(b"ftyp");
4332 bytes.extend_from_slice(b"avis");
4333 bytes.extend_from_slice(&0_u32.to_be_bytes());
4334 bytes.extend_from_slice(b"avis");
4335 bytes.extend_from_slice(b"avif");
4336 bytes.extend_from_slice(&moov);
4337
4338 let artifact =
4339 sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff an animated avif");
4340
4341 assert_eq!(artifact.metadata.frame_count, 7);
4342 }
4343
4344 #[test]
4345 fn sniff_artifact_counts_the_frames_of_an_animated_png() {
4346 let mut actl = Vec::new();
4349 actl.extend_from_slice(&4_u32.to_be_bytes());
4350 actl.extend_from_slice(&0_u32.to_be_bytes());
4351 let artifact = sniff_artifact(RawArtifact::new(
4352 png_bytes_with_chunks(2, &[(b"acTL", actl)]),
4353 None,
4354 ))
4355 .expect("sniff an animated png");
4356
4357 assert_eq!(artifact.metadata.frame_count, 4);
4358 }
4359
4360 #[test]
4361 fn sniff_artifact_counts_the_frames_of_an_animated_webp() {
4362 const ANIMATION: u8 = 0b0000_0010;
4365 let mut bytes = webp_vp8x_bytes(8, 8, ANIMATION);
4366 for _ in 0..3 {
4367 bytes.extend_from_slice(b"ANMF");
4368 bytes.extend_from_slice(&0_u32.to_le_bytes());
4369 }
4370 let riff_len = (bytes.len() - 8) as u32;
4371 bytes[4..8].copy_from_slice(&riff_len.to_le_bytes());
4372 let artifact =
4373 sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff an animated webp");
4374
4375 assert!(
4376 artifact.metadata.frame_count > 1,
4377 "an animated webp reported {} frames",
4378 artifact.metadata.frame_count
4379 );
4380 }
4381
4382 #[test]
4383 fn sniff_artifact_reads_png_transparency_from_a_trns_chunk() {
4384 let cases: &[(u8, Vec<u8>, bool)] = &[
4388 (0, vec![0x00, 0x01], true),
4389 (2, vec![0x00, 0x01, 0x00, 0x02, 0x00, 0x03], true),
4390 (3, vec![0x00, 0xFF], true),
4391 (0, Vec::new(), false),
4392 (2, Vec::new(), false),
4393 (3, Vec::new(), false),
4394 ];
4395
4396 for (color_type, trns, expected) in cases {
4397 let chunks: Vec<(&[u8; 4], Vec<u8>)> = if trns.is_empty() {
4398 Vec::new()
4399 } else {
4400 vec![(b"tRNS", trns.clone())]
4401 };
4402 let artifact = sniff_artifact(RawArtifact::new(
4403 png_bytes_with_chunks(*color_type, &chunks),
4404 None,
4405 ))
4406 .expect("sniff png");
4407
4408 assert_eq!(
4409 artifact.metadata.has_alpha,
4410 Some(*expected),
4411 "color type {color_type} with {} bytes of tRNS",
4412 trns.len()
4413 );
4414 }
4415
4416 for color_type in [4_u8, 6] {
4418 let artifact = sniff_artifact(RawArtifact::new(
4419 png_bytes_with_chunks(color_type, &[]),
4420 None,
4421 ))
4422 .expect("sniff png");
4423 assert_eq!(artifact.metadata.has_alpha, Some(true));
4424 }
4425 }
4426
4427 #[test]
4428 fn sniff_gif_rejects_a_header_shorter_than_the_screen_descriptor() {
4429 let err = sniff_artifact(RawArtifact::new(b"GIF89a\x04\x00".to_vec(), None))
4430 .expect_err("a 9-byte gif should be rejected");
4431
4432 assert!(
4433 matches!(err, TransformError::DecodeFailed(ref msg) if msg.contains("too short")),
4434 "expected a too-short decode error, got: {err}"
4435 );
4436 }
4437
4438 #[test]
4439 fn sniff_gif_rejects_a_file_truncated_inside_a_data_block() {
4440 let mut bytes = gif_bytes(b"89a", 4, 4, 1, false);
4441 bytes.truncate(bytes.len() - 2);
4443 let err = sniff_artifact(RawArtifact::new(bytes, None))
4444 .expect_err("a truncated gif should be rejected");
4445
4446 assert!(
4447 matches!(err, TransformError::DecodeFailed(ref msg) if msg.contains("ends inside a data block")),
4448 "expected a truncated-block decode error, got: {err}"
4449 );
4450 }
4451
4452 #[test]
4453 fn sniff_gif_rejects_a_file_with_no_image_data() {
4454 let err = sniff_artifact(RawArtifact::new(gif_bytes(b"89a", 4, 4, 0, false), None))
4455 .expect_err("a gif with no frames should be rejected");
4456
4457 assert!(
4458 matches!(err, TransformError::DecodeFailed(ref msg) if msg.contains("no image data")),
4459 "expected a no-image-data decode error, got: {err}"
4460 );
4461 }
4462
4463 #[test]
4464 fn sniff_gif_rejects_an_unknown_block_introducer() {
4465 let mut bytes = gif_bytes(b"89a", 4, 4, 1, false);
4466 let last = bytes.len() - 1;
4469 bytes[last] = 0x99;
4470 let err = sniff_artifact(RawArtifact::new(bytes, None))
4471 .expect_err("an unknown block introducer should be rejected");
4472
4473 assert!(
4474 matches!(err, TransformError::DecodeFailed(ref msg) if msg.contains("unknown block introducer")),
4475 "expected an unknown-introducer decode error, got: {err}"
4476 );
4477 }
4478
4479 #[test]
4480 fn sniff_gif_skips_a_local_color_table() {
4481 let mut bytes = Vec::new();
4484 bytes.extend_from_slice(b"GIF89a");
4485 bytes.extend_from_slice(&4u16.to_le_bytes());
4486 bytes.extend_from_slice(&4u16.to_le_bytes());
4487 bytes.extend_from_slice(&[0x00, 0x00, 0x00]); bytes.push(0x2C);
4489 bytes.extend_from_slice(&0u16.to_le_bytes());
4490 bytes.extend_from_slice(&0u16.to_le_bytes());
4491 bytes.extend_from_slice(&4u16.to_le_bytes());
4492 bytes.extend_from_slice(&4u16.to_le_bytes());
4493 bytes.push(0b1000_0001); bytes.extend_from_slice(&[0u8; 12]);
4495 bytes.push(0x02);
4496 bytes.extend_from_slice(&[0x02, 0x44, 0x01, 0x00]);
4497 bytes.push(0x3B);
4498
4499 let artifact =
4500 sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff gif with local palette");
4501 assert_eq!(artifact.metadata.frame_count, 1);
4502 assert_eq!(artifact.metadata.width, Some(4));
4503 }
4504
4505 #[test]
4506 fn sniff_artifact_detects_webp_vp8x_dimensions() {
4507 let artifact = sniff_artifact(RawArtifact::new(
4508 webp_vp8x_bytes(800, 600, 0b0001_0000),
4509 None,
4510 ))
4511 .expect("sniff webp vp8x");
4512
4513 assert_eq!(artifact.media_type, MediaType::Webp);
4514 assert_eq!(artifact.metadata.width, Some(800));
4515 assert_eq!(artifact.metadata.height, Some(600));
4516 assert_eq!(artifact.metadata.has_alpha, Some(true));
4517 }
4518
4519 #[test]
4520 fn sniff_artifact_detects_webp_vp8l_dimensions() {
4521 let artifact = sniff_artifact(RawArtifact::new(webp_vp8l_bytes(123, 77), None))
4522 .expect("sniff webp vp8l");
4523
4524 assert_eq!(artifact.media_type, MediaType::Webp);
4525 assert_eq!(artifact.metadata.width, Some(123));
4526 assert_eq!(artifact.metadata.height, Some(77));
4527 assert_eq!(artifact.metadata.has_alpha, Some(false));
4528 }
4529
4530 #[test]
4531 fn sniff_artifact_reads_the_webp_vp8l_alpha_bit() {
4532 let artifact = sniff_artifact(RawArtifact::new(
4533 webp_vp8l_bytes_with_alpha(123, 77, true),
4534 None,
4535 ))
4536 .expect("sniff webp vp8l");
4537
4538 assert_eq!(artifact.metadata.has_alpha, Some(true));
4539 }
4540
4541 #[test]
4542 fn sniff_artifact_detects_avif_brand() {
4543 let artifact = sniff_artifact(RawArtifact::new(avif_bytes(), None)).expect("sniff avif");
4544
4545 assert_eq!(artifact.media_type, MediaType::Avif);
4546 assert_eq!(artifact.metadata, ArtifactMetadata::default());
4547 }
4548
4549 #[cfg(feature = "avif")]
4550 #[test]
4551 fn sniff_artifact_detects_avif_dimensions_and_alpha() {
4552 let artifact = sniff_artifact(RawArtifact::new(
4553 encoded_avif_bytes(7, 5, Rgba([10, 20, 30, 0])),
4554 None,
4555 ))
4556 .expect("sniff avif with alpha");
4557
4558 assert_eq!(artifact.media_type, MediaType::Avif);
4559 assert_eq!(artifact.metadata.width, Some(7));
4560 assert_eq!(artifact.metadata.height, Some(5));
4561 assert_eq!(artifact.metadata.has_alpha, Some(true));
4562 }
4563
4564 #[cfg(feature = "avif")]
4565 #[test]
4566 fn sniff_artifact_detects_opaque_avif_without_alpha_item() {
4567 let artifact = sniff_artifact(RawArtifact::new(
4568 encoded_avif_bytes(9, 4, Rgba([10, 20, 30, 255])),
4569 None,
4570 ))
4571 .expect("sniff opaque avif");
4572
4573 assert_eq!(artifact.media_type, MediaType::Avif);
4574 assert_eq!(artifact.metadata.width, Some(9));
4575 assert_eq!(artifact.metadata.height, Some(4));
4576 assert_eq!(artifact.metadata.has_alpha, Some(false));
4577 }
4578
4579 fn mp4_box(box_type: &[u8; 4], payload: &[u8]) -> Vec<u8> {
4580 let mut bytes = Vec::new();
4581 bytes.extend_from_slice(
4582 &u32::try_from(payload.len() + 8)
4583 .expect("box size")
4584 .to_be_bytes(),
4585 );
4586 bytes.extend_from_slice(box_type);
4587 bytes.extend_from_slice(payload);
4588 bytes
4589 }
4590
4591 fn mp4_full_box(box_type: &[u8; 4], version: u8, flags: u32, payload: &[u8]) -> Vec<u8> {
4592 let mut body = vec![version];
4593 body.extend_from_slice(&flags.to_be_bytes()[1..]);
4594 body.extend_from_slice(payload);
4595 mp4_box(box_type, &body)
4596 }
4597
4598 fn avif_ispe(width: u32, height: u32) -> Vec<u8> {
4599 let mut payload = width.to_be_bytes().to_vec();
4600 payload.extend_from_slice(&height.to_be_bytes());
4601 mp4_full_box(b"ispe", 0, 0, &payload)
4602 }
4603
4604 fn avif_ipma(version: u8, flags: u32, associations: &[(u32, &[u16])]) -> Vec<u8> {
4607 let mut payload = u32::try_from(associations.len())
4608 .expect("entry count")
4609 .to_be_bytes()
4610 .to_vec();
4611 for (item, positions) in associations {
4612 if version == 0 {
4613 payload.extend_from_slice(&u16::try_from(*item).expect("item id").to_be_bytes());
4614 } else {
4615 payload.extend_from_slice(&item.to_be_bytes());
4616 }
4617 payload.push(u8::try_from(positions.len()).expect("association count"));
4618 for position in *positions {
4619 if flags & 1 == 1 {
4620 payload.extend_from_slice(&position.to_be_bytes());
4621 } else {
4622 payload.push(u8::try_from(*position).expect("narrow position"));
4623 }
4624 }
4625 }
4626 mp4_full_box(b"ipma", version, flags, &payload)
4627 }
4628
4629 fn avif_bytes_with_properties(
4632 primary_item: u32,
4633 properties: &[Vec<u8>],
4634 ipma: Vec<u8>,
4635 ) -> Vec<u8> {
4636 let pitm = mp4_full_box(
4637 b"pitm",
4638 0,
4639 0,
4640 &u16::try_from(primary_item).expect("item id").to_be_bytes(),
4641 );
4642 let ipco = mp4_box(b"ipco", &properties.concat());
4643 let iprp = mp4_box(b"iprp", &[ipco, ipma].concat());
4644 let meta = mp4_full_box(b"meta", 0, 0, &[pitm, iprp].concat());
4645 let mut bytes = avif_bytes();
4646 bytes.extend_from_slice(&meta);
4647 bytes
4648 }
4649
4650 fn avif_bytes_with_transforms(rotation: Option<u8>, mirror: Option<u8>) -> Vec<u8> {
4651 let mut properties = vec![avif_ispe(40, 20)];
4652 let mut positions = vec![1_u16];
4653 if let Some(angle) = rotation {
4654 properties.push(mp4_box(b"irot", &[angle]));
4655 positions.push(u16::try_from(properties.len()).expect("position"));
4656 }
4657 if let Some(mode) = mirror {
4658 properties.push(mp4_box(b"imir", &[mode]));
4659 positions.push(u16::try_from(properties.len()).expect("position"));
4660 }
4661 avif_bytes_with_properties(1, &properties, avif_ipma(0, 0, &[(1, &positions)]))
4662 }
4663
4664 #[rstest]
4667 #[case(None, None, None)]
4668 #[case(Some(0), None, Some(1))]
4669 #[case(Some(1), None, Some(8))]
4670 #[case(Some(2), None, Some(3))]
4671 #[case(Some(3), None, Some(6))]
4672 #[case(None, Some(0), Some(4))]
4673 #[case(None, Some(1), Some(2))]
4674 #[case(Some(1), Some(0), Some(5))]
4675 #[case(Some(1), Some(1), Some(7))]
4676 #[case(Some(2), Some(0), Some(2))]
4677 #[case(Some(2), Some(1), Some(4))]
4678 #[case(Some(3), Some(0), Some(7))]
4679 #[case(Some(3), Some(1), Some(5))]
4680 fn sniff_artifact_folds_avif_irot_and_imir_into_an_orientation(
4681 #[case] rotation: Option<u8>,
4682 #[case] mirror: Option<u8>,
4683 #[case] expected: Option<u16>,
4684 ) {
4685 let bytes = avif_bytes_with_transforms(rotation, mirror);
4686 let artifact = sniff_artifact(RawArtifact::new(bytes.clone(), None)).expect("sniff avif");
4687
4688 assert_eq!(
4689 artifact.metadata.orientation, expected,
4690 "irot {rotation:?}, imir {mirror:?}"
4691 );
4692 assert_eq!(
4693 (artifact.metadata.width, artifact.metadata.height),
4694 (Some(40), Some(20)),
4695 "the dimensions are still read from the same property container"
4696 );
4697 assert_eq!(
4698 exif_orientation(MediaType::Avif, &bytes),
4699 expected,
4700 "the pipeline reads what the sniffer reports"
4701 );
4702 }
4703
4704 #[test]
4707 fn sniff_artifact_ignores_avif_transforms_on_other_items() {
4708 let properties = vec![avif_ispe(40, 20), mp4_box(b"irot", &[3])];
4709 let bytes =
4710 avif_bytes_with_properties(1, &properties, avif_ipma(0, 0, &[(1, &[1]), (2, &[1, 2])]));
4711
4712 let artifact = sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff avif");
4713
4714 assert_eq!(artifact.metadata.orientation, None);
4715 }
4716
4717 #[test]
4719 fn sniff_artifact_reads_avif_associations_in_the_wide_ipma_encoding() {
4720 let properties = vec![avif_ispe(40, 20), mp4_box(b"irot", &[3])];
4721 let bytes = avif_bytes_with_properties(1, &properties, avif_ipma(1, 1, &[(1, &[1, 2])]));
4722
4723 let artifact = sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff avif");
4724
4725 assert_eq!(artifact.metadata.orientation, Some(6));
4726 }
4727
4728 #[test]
4731 fn sniff_artifact_applies_avif_rotation_before_mirror_whatever_the_listed_order() {
4732 let properties = vec![
4733 avif_ispe(40, 20),
4734 mp4_box(b"imir", &[1]),
4735 mp4_box(b"irot", &[3]),
4736 ];
4737 let bytes = avif_bytes_with_properties(1, &properties, avif_ipma(0, 0, &[(1, &[3, 2, 1])]));
4738
4739 let artifact = sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff avif");
4740
4741 assert_eq!(artifact.metadata.orientation, Some(5));
4742 }
4743
4744 #[test]
4746 fn sniff_artifact_rejects_a_truncated_avif_ipma() {
4747 let ipma = mp4_full_box(b"ipma", 0, 0, &5_u32.to_be_bytes());
4748 let bytes = avif_bytes_with_properties(1, &[avif_ispe(4, 4)], ipma);
4749
4750 let error = sniff_artifact(RawArtifact::new(bytes, None)).expect_err("truncated ipma");
4751
4752 assert!(
4753 error.to_string().contains("ipma box is too short"),
4754 "{error}"
4755 );
4756 }
4757
4758 fn avif_clap(width: u32, height: u32, horizontal: i32, vertical: i32) -> Vec<u8> {
4759 let mut payload = Vec::new();
4760 for value in [width, 1, height, 1] {
4761 payload.extend_from_slice(&value.to_be_bytes());
4762 }
4763 for offset in [horizontal, vertical] {
4764 payload.extend_from_slice(&offset.to_be_bytes());
4765 payload.extend_from_slice(&1_u32.to_be_bytes());
4766 }
4767 mp4_box(b"clap", &payload)
4768 }
4769
4770 #[rstest]
4779 #[case::a_fraction_that_does_not_divide(4_000_000_000, 4_000_000_000, None)]
4780 #[case::a_fraction_that_does(u32::MAX, u32::MAX, Some((1, 1)))]
4781 fn sniff_artifact_places_a_clean_aperture_without_overflowing(
4782 #[case] picture: u32,
4783 #[case] denominator: u32,
4784 #[case] expected: Option<(u32, u32)>,
4785 ) {
4786 let mut clap = Vec::new();
4787 for value in [denominator, denominator, denominator, denominator] {
4791 clap.extend_from_slice(&value.to_be_bytes());
4792 }
4793 for _ in 0..2 {
4794 clap.extend_from_slice(&0_i32.to_be_bytes());
4795 clap.extend_from_slice(&denominator.to_be_bytes());
4796 }
4797 let bytes = avif_bytes_with_properties(
4798 1,
4799 &[avif_ispe(picture, picture), mp4_box(b"clap", &clap)],
4800 avif_ipma(0, 0, &[(1, &[1, 2])]),
4801 );
4802
4803 match (sniff_artifact(RawArtifact::new(bytes, None)), expected) {
4804 (Ok(artifact), Some((width, height))) => {
4805 assert_eq!(
4806 (artifact.metadata.width, artifact.metadata.height),
4807 (Some(width), Some(height))
4808 );
4809 }
4810 (Err(TransformError::DecodeFailed(_)), None) => {}
4811 (actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
4812 }
4813 }
4814
4815 #[rstest]
4818 #[case::centred(30, 20, 0, 0, None, (30, 20), (30, 20))]
4819 #[case::offset_to_the_left(30, 20, -5, 0, None, (30, 20), (30, 20))]
4820 #[case::then_rotated(30, 20, 0, 0, Some(3), (30, 20), (20, 30))]
4821 #[case::whole_picture(40, 20, 0, 0, None, (40, 20), (40, 20))]
4822 fn sniff_artifact_reports_the_avif_clean_aperture_as_the_picture(
4823 #[case] width: u32,
4824 #[case] height: u32,
4825 #[case] horizontal: i32,
4826 #[case] vertical: i32,
4827 #[case] rotation: Option<u8>,
4828 #[case] expected: (u32, u32),
4829 #[case] expected_oriented: (u32, u32),
4830 ) {
4831 let mut properties = vec![
4832 avif_ispe(40, 20),
4833 avif_clap(width, height, horizontal, vertical),
4834 ];
4835 let mut positions = vec![1_u16, 2];
4836 if let Some(angle) = rotation {
4837 properties.push(mp4_box(b"irot", &[angle]));
4838 positions.push(3);
4839 }
4840 let bytes = avif_bytes_with_properties(1, &properties, avif_ipma(0, 0, &[(1, &positions)]));
4841
4842 let artifact = sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff avif");
4843
4844 assert_eq!(
4845 (artifact.metadata.width, artifact.metadata.height),
4846 (Some(expected.0), Some(expected.1))
4847 );
4848 assert_eq!(
4849 artifact.metadata.oriented_dimensions(),
4850 Some(Dimensions::new(expected_oriented.0, expected_oriented.1))
4851 );
4852 }
4853
4854 #[rstest]
4858 #[case::off_the_pixel_grid(31, 20, 0, 0, "does not land on a whole pixel")]
4859 #[case::wider_than_the_picture(50, 20, 0, 0, "larger than the 40-pixel picture")]
4860 #[case::pushed_out_of_the_picture(30, 20, 6, 0, "leaves the picture")]
4861 fn sniff_artifact_refuses_an_avif_clean_aperture_that_is_not_a_pixel_rectangle(
4862 #[case] width: u32,
4863 #[case] height: u32,
4864 #[case] horizontal: i32,
4865 #[case] vertical: i32,
4866 #[case] reason: &str,
4867 ) {
4868 let properties = vec![
4869 avif_ispe(40, 20),
4870 avif_clap(width, height, horizontal, vertical),
4871 ];
4872 let bytes = avif_bytes_with_properties(1, &properties, avif_ipma(0, 0, &[(1, &[1, 2])]));
4873
4874 let error = sniff_artifact(RawArtifact::new(bytes, None)).expect_err("refused");
4875
4876 assert!(error.to_string().contains(reason), "{error}");
4877 }
4878
4879 #[test]
4882 fn sniff_artifact_reads_the_clean_aperture_of_a_patched_avif() {
4883 let cropped = include_bytes!("../integration/fixtures/clap-cropped.avif");
4884 let rotated = include_bytes!("../integration/fixtures/clap-rotated.avif");
4885
4886 let cropped = sniff_artifact(RawArtifact::new(cropped.to_vec(), None)).expect("sniff");
4887 assert_eq!(
4888 (cropped.metadata.width, cropped.metadata.height),
4889 (Some(30), Some(20))
4890 );
4891 assert_eq!(cropped.metadata.orientation, None);
4892
4893 let rotated = sniff_artifact(RawArtifact::new(rotated.to_vec(), None)).expect("sniff");
4894 assert_eq!(
4895 (rotated.metadata.width, rotated.metadata.height),
4896 (Some(30), Some(20))
4897 );
4898 assert_eq!(rotated.metadata.orientation, Some(6));
4899 assert_eq!(
4900 rotated.metadata.oriented_dimensions(),
4901 Some(Dimensions::new(20, 30))
4902 );
4903 }
4904
4905 #[test]
4909 fn sniff_artifact_reads_the_orientation_libheif_writes() {
4910 let rotated = include_bytes!("../integration/fixtures/irot-rotated.avif");
4911 let transposed = include_bytes!("../integration/fixtures/imir-transposed-5.avif");
4912
4913 let rotated = sniff_artifact(RawArtifact::new(rotated.to_vec(), None)).expect("sniff");
4914 assert_eq!(rotated.metadata.orientation, Some(6));
4915 assert_eq!(
4916 (rotated.metadata.width, rotated.metadata.height),
4917 (Some(40), Some(20))
4918 );
4919 assert_eq!(
4920 rotated.metadata.oriented_dimensions(),
4921 Some(Dimensions::new(20, 40)),
4922 "the oriented dimensions are what convert will produce"
4923 );
4924
4925 let transposed =
4926 sniff_artifact(RawArtifact::new(transposed.to_vec(), None)).expect("sniff");
4927 assert_eq!(transposed.metadata.orientation, Some(5));
4928 }
4929
4930 #[test]
4931 fn sniff_artifact_rejects_declared_media_type_mismatch() {
4932 let err = sniff_artifact(RawArtifact::new(
4933 png_ihdr_bytes(8, 8, 2),
4934 Some(MediaType::Jpeg),
4935 ))
4936 .expect_err("declared mismatch should fail");
4937
4938 assert_eq!(
4939 err,
4940 TransformError::InvalidInput(
4941 "declared media type does not match detected media type".to_string()
4942 )
4943 );
4944 }
4945
4946 #[test]
4952 fn a_quality_outside_the_documented_range_reports_that_range_at_any_width() {
4953 for value in [0_i64, 101, 255, 256, 999_999, -1, i64::MAX, i64::MIN] {
4954 assert_eq!(
4955 validate_quality_value(value),
4956 Err("quality must be between 1 and 100"),
4957 "{value}"
4958 );
4959 }
4960 for value in [1_i64, 50, 100] {
4961 assert_eq!(validate_quality_value(value), Ok(value as u8), "{value}");
4962 }
4963 }
4964
4965 #[test]
4971 fn a_dimension_that_cannot_be_a_pixel_count_says_which_half_is_wrong() {
4972 for value in [1_i64, 2, 100, u32::MAX as i64] {
4973 assert_eq!(validate_width_value(value), Ok(value as u32), "{value}");
4974 assert_eq!(validate_height_value(value), Ok(value as u32), "{value}");
4975 }
4976 assert_eq!(validate_width_value(0), Ok(0));
4978 assert_eq!(validate_height_value(0), Ok(0));
4979
4980 for value in [-1_i64, i64::MIN] {
4981 assert_eq!(
4982 validate_width_value(value),
4983 Err("width must be greater than zero"),
4984 "{value}"
4985 );
4986 assert_eq!(
4987 validate_height_value(value),
4988 Err("height must be greater than zero"),
4989 "{value}"
4990 );
4991 }
4992 for value in [u32::MAX as i64 + 1, i64::MAX] {
4993 assert_eq!(
4994 validate_width_value(value),
4995 Err("width is too large to be a number of pixels"),
4996 "{value}"
4997 );
4998 assert_eq!(
4999 validate_height_value(value),
5000 Err("height is too large to be a number of pixels"),
5001 "{value}"
5002 );
5003 }
5004 }
5005
5006 #[test]
5008 fn a_watermark_opacity_outside_the_documented_range_reports_that_range_at_any_width() {
5009 for value in [0_i64, 101, 255, 256, -1, i64::MAX] {
5010 assert_eq!(
5011 validate_watermark_opacity_value(value),
5012 Err("watermark opacity must be between 1 and 100"),
5013 "{value}"
5014 );
5015 }
5016 assert_eq!(validate_watermark_opacity_value(50), Ok(50));
5017 }
5018
5019 #[test]
5025 fn a_rotation_past_a_full_turn_wraps_however_large_it_is() {
5026 use std::str::FromStr;
5027 assert_eq!(
5028 Rotation::from_str("9999999999").expect("a whole number of degrees"),
5029 Rotation::from_str("279").expect("279")
5030 );
5031 assert_eq!(
5032 Rotation::from_str("-9999999999").expect("a whole number of degrees"),
5033 Rotation::from_str("81").expect("81")
5034 );
5035 assert_eq!(
5036 Rotation::from_str("2147483648").expect("one past i32"),
5037 Rotation::from_str(&(2147483648_i64 % 360).to_string()).expect("wrapped")
5038 );
5039 let error = Rotation::from_str("1.5").expect_err("not a whole number");
5041 assert!(error.contains("whole number of degrees"), "{error}");
5042 }
5043
5044 #[test]
5045 fn sniff_artifact_rejects_unknown_signatures() {
5046 let err =
5047 sniff_artifact(RawArtifact::new(vec![1, 2, 3, 4], None)).expect_err("unknown bytes");
5048
5049 assert!(
5050 matches!(err, TransformError::UnsupportedInputMediaType(ref msg) if msg.contains("unknown file signature")),
5051 "expected unknown file signature error, got: {err}"
5052 );
5053 let msg = err.to_string();
5054 assert!(msg.contains("4 bytes"), "should include file size: {msg}");
5055 assert!(
5056 !msg.contains("01 02 03 04"),
5057 "the bytes themselves are content, and this message reaches whoever named a URL: {msg}"
5058 );
5059 }
5060
5061 #[rstest]
5067 #[case::no_prolog(r#"<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>"#)]
5068 #[case::declaration(
5069 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5070 )]
5071 #[case::declaration_and_doctype(
5072 "<?xml version=\"1.0\"?>\n<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5073 )]
5074 #[case::comment_before_doctype(
5075 "<?xml version=\"1.0\"?>\n<!-- Generator: Adobe Illustrator 27.0.0 -->\n<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5076 )]
5077 #[case::doctype_with_internal_subset(
5078 "<?xml version=\"1.0\"?>\n<!DOCTYPE svg [<!ENTITY a \"b\">]>\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5079 )]
5080 #[case::illustrator_export(
5081 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- Generator: Adobe Illustrator 27.0.0, SVG Export Plug-In -->\n<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\" [\n\t<!ENTITY ns_extend \"http://ns.adobe.com/Extensibility/1.0/\">\n]>\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5082 )]
5083 #[case::internal_subset_with_angle_bracket_in_a_string(
5084 "<?xml version=\"1.0\"?>\n<!DOCTYPE svg [<!ENTITY gt \"a > b\">]>\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5085 )]
5086 #[case::stylesheet_processing_instruction(
5087 "<?xml version=\"1.0\"?>\n<?xml-stylesheet type=\"text/css\" href=\"a.css\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5088 )]
5089 #[case::processing_instruction_between_comments(
5090 "<?xml version=\"1.0\"?>\n<!-- one -->\n<?foo bar?>\n<!-- two -->\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5091 )]
5092 #[case::comment_on_both_sides_of_the_doctype(
5093 "<!-- before -->\n<!DOCTYPE svg>\n<!-- after -->\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5094 )]
5095 #[case::bom_then_declaration(
5096 "\u{FEFF}<?xml version=\"1.0\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5097 )]
5098 fn sniff_artifact_accepts_every_legal_svg_prolog(#[case] document: &str) {
5099 let artifact = sniff_artifact(RawArtifact::new(document.as_bytes().to_vec(), None))
5100 .unwrap_or_else(|err| panic!("prolog should be recognized as SVG, got: {err}"));
5101 assert_eq!(artifact.media_type, MediaType::Svg);
5102 }
5103
5104 #[rstest]
5109 #[case::bare_numbers(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"/>"#, Some((100, 50)))]
5110 #[case::px(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100px" height="50px"/>"#, Some((100, 50)))]
5111 #[case::decimal(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100.6" height="50.2"/>"#, Some((100, 50)))]
5112 #[case::inches(r#"<svg xmlns="http://www.w3.org/2000/svg" width="1in" height="2in"/>"#, Some((96, 192)))]
5113 #[case::points(r#"<svg xmlns="http://www.w3.org/2000/svg" width="72pt" height="36pt"/>"#, Some((96, 48)))]
5114 #[case::picas(r#"<svg xmlns="http://www.w3.org/2000/svg" width="1pc" height="2pc"/>"#, Some((16, 32)))]
5115 #[case::whitespace_around_the_value(r#"<svg xmlns="http://www.w3.org/2000/svg" width=" 100 " height=" 50 "/>"#, Some((100, 50)))]
5116 #[case::single_quoted(r"<svg xmlns='http://www.w3.org/2000/svg' width='100' height='50'/>", Some((100, 50)))]
5117 #[case::view_box_only(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60"/>"#, Some((120, 60)))]
5118 #[case::view_box_with_commas(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0,0,120,60"/>"#, Some((120, 60)))]
5119 #[case::percentages_fall_back_to_the_view_box(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 30 20"/>"#, Some((30, 20)))]
5120 #[case::one_axis_takes_its_aspect_ratio_from_the_view_box(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" viewBox="0 0 30 20"/>"#, Some((100, 66)))]
5121 #[case::font_relative_units_are_unresolvable(
5122 r#"<svg xmlns="http://www.w3.org/2000/svg" width="10em" height="4em"/>"#,
5123 None
5124 )]
5125 #[case::percentages_with_no_view_box(
5126 r#"<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"/>"#,
5127 None
5128 )]
5129 #[case::nothing_declared(r#"<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>"#, None)]
5130 #[case::zero_is_not_a_size(
5131 r#"<svg xmlns="http://www.w3.org/2000/svg" width="0" height="50"/>"#,
5132 None
5133 )]
5134 #[case::negative_is_not_a_size(
5135 r#"<svg xmlns="http://www.w3.org/2000/svg" width="-100" height="50"/>"#,
5136 None
5137 )]
5138 #[case::malformed_view_box(
5139 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120"/>"#,
5140 None
5141 )]
5142 #[case::illustrator_prolog(
5143 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- Generator: Adobe Illustrator 27.0.0 -->\n<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\" [\n\t<!ENTITY ns_extend \"http://ns.adobe.com/Extensibility/1.0/\">\n]>\n<svg xmlns=\"http://www.w3.org/2000/svg\" x=\"0px\" y=\"0px\" width=\"64px\" height=\"32px\" viewBox=\"0 0 64 32\"><rect/></svg>",
5144 Some((64, 32))
5145 )]
5146 fn sniff_artifact_reads_svg_dimensions_from_the_root_element(
5147 #[case] document: &str,
5148 #[case] expected: Option<(u32, u32)>,
5149 ) {
5150 let artifact = sniff_artifact(RawArtifact::new(document.as_bytes().to_vec(), None))
5151 .expect("document should be recognized as SVG");
5152
5153 assert_eq!(artifact.media_type, MediaType::Svg);
5154 assert_eq!(
5155 artifact.metadata.width.zip(artifact.metadata.height),
5156 expected
5157 );
5158 assert_eq!(
5159 artifact
5160 .metadata
5161 .oriented_dimensions()
5162 .map(|d| (d.width, d.height)),
5163 expected
5164 );
5165 }
5166
5167 #[rstest]
5170 #[case::xhtml_root(
5171 "<?xml version=\"1.0\"?>\n<html xmlns=\"http://www.w3.org/1999/xhtml\"><body/></html>"
5172 )]
5173 #[case::element_with_an_svg_prefix("<?xml version=\"1.0\"?>\n<svgfoo/>")]
5174 #[case::prolog_with_no_root("<?xml version=\"1.0\"?>\n<!-- only a comment -->")]
5175 #[case::unterminated_declaration("<?xml version=\"1.0\"\n<svg/>")]
5176 #[case::unterminated_comment("<!-- never closed\n<svg/>")]
5177 #[case::unterminated_internal_subset("<!DOCTYPE svg [<!ENTITY a \"b\">\n<svg/>")]
5178 fn sniff_artifact_does_not_claim_non_svg_documents(#[case] document: &str) {
5179 let result = sniff_artifact(RawArtifact::new(document.as_bytes().to_vec(), None));
5180 assert!(
5181 result.is_err(),
5182 "should not be claimed as SVG: {document:?} produced {result:?}"
5183 );
5184 }
5185
5186 #[test]
5187 fn sniff_artifact_rejects_invalid_png_structure() {
5188 let err = sniff_artifact(RawArtifact::new(b"\x89PNG\r\n\x1a\nbroken".to_vec(), None))
5189 .expect_err("broken png should fail");
5190
5191 assert_eq!(
5192 err,
5193 TransformError::DecodeFailed("png file is too short".to_string())
5194 );
5195 }
5196
5197 #[test]
5198 fn sniff_artifact_detects_bmp_dimensions() {
5199 let mut bmp = Vec::new();
5202 bmp.extend_from_slice(b"BM");
5204 bmp.extend_from_slice(&0u32.to_le_bytes());
5206 bmp.extend_from_slice(&0u32.to_le_bytes());
5208 bmp.extend_from_slice(&54u32.to_le_bytes());
5210 bmp.extend_from_slice(&40u32.to_le_bytes());
5212 bmp.extend_from_slice(&8u32.to_le_bytes());
5214 bmp.extend_from_slice(&6i32.to_le_bytes());
5216 bmp.extend_from_slice(&1u16.to_le_bytes());
5218 bmp.extend_from_slice(&24u16.to_le_bytes());
5220 bmp.resize(54, 0);
5222
5223 let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
5224 assert_eq!(artifact.media_type, MediaType::Bmp);
5225 assert_eq!(artifact.metadata.width, Some(8));
5226 assert_eq!(artifact.metadata.height, Some(6));
5227 assert_eq!(artifact.metadata.has_alpha, Some(false));
5228 }
5229
5230 #[test]
5231 fn sniff_artifact_detects_bmp_32bit_alpha() {
5232 let mut bmp = Vec::new();
5233 bmp.extend_from_slice(b"BM");
5234 bmp.extend_from_slice(&0u32.to_le_bytes());
5235 bmp.extend_from_slice(&0u32.to_le_bytes());
5236 bmp.extend_from_slice(&54u32.to_le_bytes());
5237 bmp.extend_from_slice(&40u32.to_le_bytes());
5238 bmp.extend_from_slice(&4u32.to_le_bytes());
5240 bmp.extend_from_slice(&4i32.to_le_bytes());
5242 bmp.extend_from_slice(&1u16.to_le_bytes());
5244 bmp.extend_from_slice(&32u16.to_le_bytes());
5246 bmp.resize(54, 0);
5247
5248 let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
5249 assert_eq!(artifact.media_type, MediaType::Bmp);
5250 assert_eq!(artifact.metadata.has_alpha, Some(true));
5251 }
5252
5253 #[test]
5254 fn sniff_artifact_rejects_too_short_bmp() {
5255 let mut data = b"BM".to_vec();
5257 data.resize(27, 0);
5258 let err =
5259 sniff_artifact(RawArtifact::new(data, None)).expect_err("too-short BMP should fail");
5260
5261 assert_eq!(
5262 err,
5263 TransformError::DecodeFailed("bmp file is too short".to_string())
5264 );
5265 }
5266
5267 #[test]
5268 fn normalize_rejects_blur_sigma_below_minimum() {
5269 let err = TransformOptions {
5270 blur: Some(0.0),
5271 ..TransformOptions::default()
5272 }
5273 .normalize(MediaType::Jpeg)
5274 .expect_err("blur sigma 0.0 should be rejected");
5275
5276 assert_eq!(
5277 err,
5278 TransformError::InvalidOptions("blur sigma must be between 0.1 and 100.0".to_string())
5279 );
5280 }
5281
5282 #[test]
5283 fn normalize_rejects_blur_sigma_above_maximum() {
5284 let err = TransformOptions {
5285 blur: Some(100.1),
5286 ..TransformOptions::default()
5287 }
5288 .normalize(MediaType::Jpeg)
5289 .expect_err("blur sigma 100.1 should be rejected");
5290
5291 assert_eq!(
5292 err,
5293 TransformError::InvalidOptions("blur sigma must be between 0.1 and 100.0".to_string())
5294 );
5295 }
5296
5297 #[test]
5298 fn normalize_accepts_blur_sigma_at_boundaries() {
5299 let opts_min = TransformOptions {
5300 blur: Some(0.1),
5301 ..TransformOptions::default()
5302 }
5303 .normalize(MediaType::Jpeg)
5304 .expect("blur sigma 0.1 should be accepted");
5305 assert_eq!(opts_min.blur, Some(0.1));
5306
5307 let opts_max = TransformOptions {
5308 blur: Some(100.0),
5309 ..TransformOptions::default()
5310 }
5311 .normalize(MediaType::Jpeg)
5312 .expect("blur sigma 100.0 should be accepted");
5313 assert_eq!(opts_max.blur, Some(100.0));
5314 }
5315
5316 #[test]
5317 fn normalize_rejects_sharpen_sigma_below_minimum() {
5318 let err = TransformOptions {
5319 sharpen: Some(0.0),
5320 ..TransformOptions::default()
5321 }
5322 .normalize(MediaType::Jpeg)
5323 .expect_err("sharpen sigma 0.0 should be rejected");
5324
5325 assert_eq!(
5326 err,
5327 TransformError::InvalidOptions(
5328 "sharpen sigma must be between 0.1 and 100.0".to_string()
5329 )
5330 );
5331 }
5332
5333 #[test]
5334 fn normalize_rejects_sharpen_sigma_above_maximum() {
5335 let err = TransformOptions {
5336 sharpen: Some(100.1),
5337 ..TransformOptions::default()
5338 }
5339 .normalize(MediaType::Jpeg)
5340 .expect_err("sharpen sigma 100.1 should be rejected");
5341
5342 assert_eq!(
5343 err,
5344 TransformError::InvalidOptions(
5345 "sharpen sigma must be between 0.1 and 100.0".to_string()
5346 )
5347 );
5348 }
5349
5350 #[test]
5351 fn normalize_accepts_sharpen_sigma_at_boundaries() {
5352 let opts_min = TransformOptions {
5353 sharpen: Some(0.1),
5354 ..TransformOptions::default()
5355 }
5356 .normalize(MediaType::Jpeg)
5357 .expect("sharpen sigma 0.1 should be accepted");
5358 assert_eq!(opts_min.sharpen, Some(0.1));
5359
5360 let opts_max = TransformOptions {
5361 sharpen: Some(100.0),
5362 ..TransformOptions::default()
5363 }
5364 .normalize(MediaType::Jpeg)
5365 .expect("sharpen sigma 100.0 should be accepted");
5366 assert_eq!(opts_max.sharpen, Some(100.0));
5367 }
5368
5369 #[test]
5370 fn validate_watermark_rejects_zero_opacity() {
5371 let wm = super::WatermarkInput {
5372 image: jpeg_artifact(),
5373 position: Position::BottomRight,
5374 opacity: 0,
5375 margin: 10,
5376 };
5377 let err = super::validate_watermark(&wm).expect_err("opacity 0 should be rejected");
5378 assert_eq!(
5379 err,
5380 TransformError::InvalidOptions(
5381 "watermark opacity must be between 1 and 100".to_string()
5382 )
5383 );
5384 }
5385
5386 #[test]
5387 fn validate_watermark_rejects_opacity_above_100() {
5388 let wm = super::WatermarkInput {
5389 image: jpeg_artifact(),
5390 position: Position::BottomRight,
5391 opacity: 101,
5392 margin: 10,
5393 };
5394 let err = super::validate_watermark(&wm).expect_err("opacity 101 should be rejected");
5395 assert_eq!(
5396 err,
5397 TransformError::InvalidOptions(
5398 "watermark opacity must be between 1 and 100".to_string()
5399 )
5400 );
5401 }
5402
5403 #[test]
5404 fn validate_watermark_rejects_svg_image() {
5405 let wm = super::WatermarkInput {
5406 image: Artifact::new(vec![1], MediaType::Svg, ArtifactMetadata::default()),
5407 position: Position::BottomRight,
5408 opacity: 50,
5409 margin: 10,
5410 };
5411 let err = super::validate_watermark(&wm).expect_err("SVG watermark should be rejected");
5412 assert_eq!(
5413 err,
5414 TransformError::InvalidOptions("watermark image must be a raster format".to_string())
5415 );
5416 }
5417
5418 #[test]
5419 fn validate_watermark_accepts_valid_input() {
5420 let wm = super::WatermarkInput {
5421 image: jpeg_artifact(),
5422 position: Position::BottomRight,
5423 opacity: 50,
5424 margin: 10,
5425 };
5426 super::validate_watermark(&wm).expect("valid watermark should be accepted");
5427 }
5428
5429 #[test]
5430 fn crop_region_from_str_valid() {
5431 use super::CropRegion;
5432 let crop: CropRegion = "10,20,100,200".parse().expect("valid crop");
5433 assert_eq!(crop.x, 10);
5434 assert_eq!(crop.y, 20);
5435 assert_eq!(crop.width, 100);
5436 assert_eq!(crop.height, 200);
5437 }
5438
5439 #[test]
5440 fn crop_region_from_str_zero_width() {
5441 use super::CropRegion;
5442 let err = "10,20,0,200"
5443 .parse::<CropRegion>()
5444 .expect_err("zero width should fail");
5445 assert!(err.contains("greater than zero"), "unexpected error: {err}");
5446 }
5447
5448 #[test]
5449 fn crop_region_from_str_wrong_parts() {
5450 use super::CropRegion;
5451 let err = "10,20,100"
5452 .parse::<CropRegion>()
5453 .expect_err("three parts should fail");
5454 assert!(
5455 err.contains("four comma-separated"),
5456 "unexpected error: {err}"
5457 );
5458 }
5459
5460 #[test]
5461 fn crop_region_display() {
5462 use super::CropRegion;
5463 let crop = CropRegion {
5464 x: 1,
5465 y: 2,
5466 width: 3,
5467 height: 4,
5468 };
5469 assert_eq!(crop.to_string(), "1,2,3,4");
5470 }
5471
5472 #[test]
5473 fn normalize_rejects_zero_dimension_crop() {
5474 use super::{CropRegion, MediaType, TransformOptions};
5475 let opts = TransformOptions {
5476 crop: Some(CropRegion {
5477 x: 0,
5478 y: 0,
5479 width: 0,
5480 height: 100,
5481 }),
5482 ..TransformOptions::default()
5483 };
5484 let err = opts
5485 .normalize(MediaType::Jpeg)
5486 .expect_err("zero-width crop should fail");
5487 assert!(
5488 matches!(err, super::TransformError::InvalidOptions(_)),
5489 "unexpected error: {err:?}"
5490 );
5491 }
5492}