From 5d7b71405ce2ffd3c31ec97bdf7b86e6d67eec84 Mon Sep 17 00:00:00 2001 From: Rafi Date: Sun, 16 Aug 2026 05:03:16 -0400 Subject: [PATCH 1/4] Restructure processing and service into focused modules options, transform and service had grown past a thousand lines each and were about to absorb a large compatibility release. Each is now a directory whose submodules own one concern: option groups parse their own arguments, transform stages sit beside the geometry they need, and the service separates cache identity, limits and source handling from request flow. The move carries several behaviour changes that the split made cheap: - gravity gains offsets and focus-point positioning, following imgproxy's calcPosition, and crop, fill and extend all route through it. A crop with no gravity now centres rather than pinning to the top-left, which is what imgproxy has always done. - crop extents below 1 are read as a fraction of the source. - resizing types are an enum and include fill-down. - zoom takes independent x and y factors. - extend takes its own gravity, and extend_aspect_ratio is implemented. - brightness and contrast are applied through vips_linear, which the crate does expose after all; the roadmap's note that it does not was wrong, and watermarking had been using it all along. - metadata keep flags are expressed as a combination, so stripping the colour profile no longer discards EXIF as a side effect. - every encoder goes through the save-suffix parser, which is what makes that combination expressible and keeps one code path across libvips versions. - an upstream 404 is reported as an upstream status instead of surfacing later as "failed to decode source image". --- src/caching/cache.rs | 14 +- src/config/env_vars.rs | 65 + src/{config.rs => config/mod.rs} | 444 ++---- src/config/tests.rs | 302 ++++ src/constants.rs | 40 + src/fetch.rs | 94 +- src/handlers.rs | 1 + src/limits.rs | 76 + src/processing/animation.rs | 172 +++ src/processing/colorspace.rs | 127 ++ src/processing/metadata.rs | 270 ++++ src/processing/mod.rs | 476 +++--- src/processing/options.rs | 1266 ---------------- src/processing/options/effects.rs | 185 +++ src/processing/options/encoder.rs | 122 ++ src/processing/options/error.rs | 134 ++ src/processing/options/geometry.rs | 317 ++++ src/processing/options/mod.rs | 687 +++++++++ src/processing/options/names.rs | 242 ++++ src/processing/pipeline.rs | 189 +++ src/processing/save.rs | 573 +++++--- src/processing/scale_on_load.rs | 124 ++ src/processing/tests.rs | 4 + src/processing/tests/animation_limit_tests.rs | 94 ++ src/processing/tests/effects_tests.rs | 207 ++- src/processing/tests/options_parse_tests.rs | 170 ++- src/processing/tests/padding_extend_tests.rs | 95 +- src/processing/tests/pipeline_tests.rs | 112 +- src/processing/tests/resize_tests.rs | 229 +-- src/processing/tests/save_tests.rs | 81 +- src/processing/tests/watermark_tests.rs | 23 +- src/processing/transform.rs | 800 ----------- src/processing/transform/effects.rs | 215 +++ src/processing/transform/geometry.rs | 263 ++++ src/processing/transform/mod.rs | 128 ++ src/processing/transform/orientation.rs | 68 + src/processing/transform/resize.rs | 314 ++++ src/processing/transform/trim.rs | 97 ++ src/processing/utils.rs | 12 +- src/processing/watermark.rs | 114 +- src/service.rs | 1271 ----------------- src/service/cache_key.rs | 207 +++ src/service/error.rs | 116 ++ src/service/mod.rs | 633 ++++++++ src/service/security.rs | 167 +++ src/service/source.rs | 304 ++++ src/service/tests.rs | 976 +++++++++++++ tests/handlers_integration_tests_extended.rs | 210 +++ 48 files changed, 8368 insertions(+), 4462 deletions(-) create mode 100644 src/config/env_vars.rs rename src/{config.rs => config/mod.rs} (50%) create mode 100644 src/config/tests.rs create mode 100644 src/processing/animation.rs create mode 100644 src/processing/colorspace.rs create mode 100644 src/processing/metadata.rs delete mode 100644 src/processing/options.rs create mode 100644 src/processing/options/effects.rs create mode 100644 src/processing/options/encoder.rs create mode 100644 src/processing/options/error.rs create mode 100644 src/processing/options/geometry.rs create mode 100644 src/processing/options/mod.rs create mode 100644 src/processing/options/names.rs create mode 100644 src/processing/pipeline.rs create mode 100644 src/processing/scale_on_load.rs create mode 100644 src/processing/tests/animation_limit_tests.rs delete mode 100644 src/processing/transform.rs create mode 100644 src/processing/transform/effects.rs create mode 100644 src/processing/transform/geometry.rs create mode 100644 src/processing/transform/mod.rs create mode 100644 src/processing/transform/orientation.rs create mode 100644 src/processing/transform/resize.rs create mode 100644 src/processing/transform/trim.rs delete mode 100644 src/service.rs create mode 100644 src/service/cache_key.rs create mode 100644 src/service/error.rs create mode 100644 src/service/mod.rs create mode 100644 src/service/security.rs create mode 100644 src/service/source.rs create mode 100644 src/service/tests.rs diff --git a/src/caching/cache.rs b/src/caching/cache.rs index 70e14ab..8052338 100644 --- a/src/caching/cache.rs +++ b/src/caching/cache.rs @@ -72,7 +72,7 @@ impl Code for CachedImage { } } -#[derive(Clone)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CachedMetadata { pub width: u32, pub height: u32, @@ -82,6 +82,8 @@ pub struct CachedMetadata { pub channels: u32, pub has_alpha: bool, pub orientation: u32, + /// Frames or pages the source carries; 1 for a still image. + pub pages: u32, } impl Code for CachedMetadata { @@ -101,6 +103,7 @@ impl Code for CachedMetadata { self.channels.encode(writer)?; self.has_alpha.encode(writer)?; self.orientation.encode(writer)?; + self.pages.encode(writer)?; Ok(()) } @@ -126,6 +129,7 @@ impl Code for CachedMetadata { let channels = u32::decode(reader)?; let has_alpha = bool::decode(reader)?; let orientation = u32::decode(reader)?; + let pages = u32::decode(reader)?; Ok(CachedMetadata { width, @@ -136,11 +140,12 @@ impl Code for CachedMetadata { channels, has_alpha, orientation, + pages, }) } fn estimated_size(&self) -> usize { - std::mem::size_of::() * 4 + std::mem::size_of::() * 5 + std::mem::size_of::() * 2 + std::mem::size_of::() + self.format.len() @@ -149,6 +154,11 @@ impl Code for CachedMetadata { } /// Represents the different cache backends for imgforge value types. +/// +/// Every backing store is behind an `Arc`, so cloning is a handle copy rather +/// than a copy of the cache — which is what lets one cache be shared by more +/// than one `AppState`. +#[derive(Clone)] pub enum TypedCache where T: Clone + Code + Send + Sync + 'static, diff --git a/src/config/env_vars.rs b/src/config/env_vars.rs new file mode 100644 index 0000000..a19f542 --- /dev/null +++ b/src/config/env_vars.rs @@ -0,0 +1,65 @@ +//! Reading and validating environment variables. + +use super::ConfigError; +use crate::limits::SecurityLimitError; +use std::env; +use std::str::FromStr; + +/// Reads a variable, distinguishing "absent" from "present but not Unicode". +/// +/// A variable that cannot be decoded is a configuration mistake, not an absent +/// setting, and silently treating it as absent would start the server with the +/// operator's intent quietly discarded. +pub(super) fn optional_var(name: &'static str) -> Result, ConfigError> { + match env::var(name) { + Ok(value) => Ok(Some(value)), + Err(env::VarError::NotPresent) => Ok(None), + Err(source @ env::VarError::NotUnicode(_)) => Err(ConfigError::InvalidUnicode { name, source }), + } +} + +/// Reads a boolean setting, accepting the same spellings the URL options do. +pub(super) fn bool_var(name: &'static str, default: bool) -> Result { + Ok(optional_var(name)? + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "t" | "true" | "yes" | "on" + ) + }) + .unwrap_or(default)) +} + +/// Reads a setting parsed by `FromStr`, failing the startup on a bad value. +pub(super) fn parsed_var(name: &'static str) -> Result, ConfigError> +where + T: FromStr, + T::Err: std::fmt::Display, +{ + let Some(value) = optional_var(name)? else { + return Ok(None); + }; + value + .trim() + .parse() + .map(Some) + .map_err(|source: T::Err| ConfigError::InvalidValue { + name, + value: value.clone(), + reason: source.to_string(), + }) +} + +/// Reads a validated security limit. +pub(super) fn security_limit_var(name: &'static str) -> Result, ConfigError> +where + T: FromStr, +{ + let Some(value) = optional_var(name)? else { + return Ok(None); + }; + value + .parse() + .map(Some) + .map_err(|source| ConfigError::InvalidSecurityLimit { name, value, source }) +} diff --git a/src/config.rs b/src/config/mod.rs similarity index 50% rename from src/config.rs rename to src/config/mod.rs index 65583df..170a017 100644 --- a/src/config.rs +++ b/src/config/mod.rs @@ -1,7 +1,15 @@ +//! Server configuration, assembled from the environment at startup. + +mod env_vars; + use crate::constants::*; -use crate::limits::{MaxResultDimension, MaxSourceFileSize, MaxSourceResolution, SecurityLimitError}; -use crate::processing::options::ProcessingOption; +use crate::limits::{ + MaxAnimationFrameResolution, MaxAnimationFrames, MaxResultDimension, MaxSourceFileSize, MaxSourceResolution, + SecurityLimitError, +}; +use crate::processing::options::{OptionDefaults, ProcessingOption}; use crate::processing::presets::{parse_options_string, PresetError}; +use env_vars::{bool_var, optional_var, parsed_var, security_limit_var}; use std::collections::HashMap; use std::env; use std::str::FromStr; @@ -46,6 +54,12 @@ pub enum ConfigError { ZeroWorkers, #[error("image-processing worker count {value} exceeds the supported maximum of {max}")] WorkerCountTooLarge { value: usize, max: usize }, + #[error("invalid value for {name} ({value:?}): {reason}")] + InvalidValue { + name: &'static str, + value: String, + reason: String, + }, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -139,6 +153,49 @@ pub struct Config { pub watermark_path: Option, pub default_format: DefaultOutputFormat, pub rate_limit_per_minute: Option, + /// Ceiling on how many frames of an animated source are decoded. + pub max_animation_frames: Option, + /// Ceiling on the pixel count of a single animation frame. + pub max_animation_frame_resolution: Option, + /// Starting values for the processing options a URL may override. + pub option_defaults: OptionDefaults, + + /// `max-age` for the `Cache-Control` header, in seconds. + pub ttl: Option, + /// Send the source's own `Cache-Control` instead of the configured TTL. + pub cache_control_passthrough: bool, + /// Emit an `ETag` and honour `If-None-Match`. + pub use_etag: bool, + /// Pass the source's `Last-Modified` through and honour `If-Modified-Since`. + pub last_modified_enabled: bool, + /// Emit a `Link: ; rel="canonical"` header. + pub set_canonical_header: bool, + /// Value for `Access-Control-Allow-Origin`, when cross-origin use is wanted. + pub allow_origin: Option, + /// Path segment every route is mounted under. + pub path_prefix: String, + /// Path the liveness endpoint answers on. + pub health_check_path: String, + /// Return the underlying error text to the client. + pub development_errors_mode: bool, + /// Emit `X-Origin-*` headers describing the source image. + pub enable_debug_headers: bool, + + /// `User-Agent` sent when fetching a source image. + pub user_agent: String, + /// How many redirects a source fetch may follow. + pub max_redirects: usize, + + /// Serve WebP when the client's `Accept` says it can read it. + pub enable_webp_detection: bool, + /// Serve WebP to a client that accepts it even when the URL asks otherwise. + pub enforce_webp: bool, + /// Serve AVIF when the client's `Accept` says it can read it. + pub enable_avif_detection: bool, + /// Serve AVIF to a client that accepts it even when the URL asks otherwise. + pub enforce_avif: bool, + /// Honour the `Width` and `DPR` client hints. + pub enable_client_hints: bool, } fn normalize_bind_address(raw: &str) -> String { @@ -252,9 +309,34 @@ impl Config { watermark_path: None, default_format: DefaultOutputFormat::default(), rate_limit_per_minute: None, + max_animation_frames: None, + max_animation_frame_resolution: None, + option_defaults: OptionDefaults::default(), + ttl: None, + cache_control_passthrough: false, + use_etag: false, + last_modified_enabled: false, + set_canonical_header: false, + allow_origin: None, + path_prefix: String::new(), + health_check_path: "/health".to_string(), + development_errors_mode: false, + enable_debug_headers: false, + user_agent: DEFAULT_USER_AGENT.to_string(), + max_redirects: 10, + enable_webp_detection: false, + enforce_webp: false, + enable_avif_detection: false, + enforce_avif: false, + enable_client_hints: false, } } + /// The processing-option defaults a request starts from. + pub fn option_defaults(&self) -> OptionDefaults { + self.option_defaults + } + /// Create a configuration from hexadecimal key and salt strings. pub fn with_hex_keys(key_hex: &str, salt_hex: &str) -> Result { let key = hex::decode(key_hex).map_err(ConfigError::InvalidKey)?; @@ -320,309 +402,71 @@ impl Config { .ok() .and_then(|s| s.parse::().ok()); - Ok(config) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::env; - use std::sync::Mutex; - - lazy_static::lazy_static! { - static ref ENV_LOCK: Mutex<()> = Mutex::new(()); - } - - fn restore_env_var(key: &str, original: Option) { - if let Some(value) = original { - env::set_var(key, value); - } else { - env::remove_var(key); - } - } - - #[test] - fn prometheus_numeric_port_maps_to_default_host() { - let _guard = ENV_LOCK.lock().unwrap(); - let original_prometheus = env::var(ENV_PROMETHEUS_BIND).ok(); - - env::set_var(ENV_PROMETHEUS_BIND, "3005"); - let config = Config::from_env().expect("config loads"); - - assert_eq!(config.prometheus_bind_address.as_deref(), Some("0.0.0.0:3005")); - - restore_env_var(ENV_PROMETHEUS_BIND, original_prometheus); - } - - #[test] - fn bind_numeric_port_maps_to_default_host() { - let _guard = ENV_LOCK.lock().unwrap(); - let original_bind = env::var(ENV_BIND).ok(); - let original_prometheus = env::var(ENV_PROMETHEUS_BIND).ok(); - - env::set_var(ENV_BIND, "3456"); - env::remove_var(ENV_PROMETHEUS_BIND); - - let config = Config::from_env().expect("config loads"); - - assert_eq!(config.bind_address, "0.0.0.0:3456"); - assert_eq!(config.prometheus_bind_address, None); - - restore_env_var(ENV_BIND, original_bind); - restore_env_var(ENV_PROMETHEUS_BIND, original_prometheus); - } - - #[test] - fn invalid_worker_count_fails_configuration() { - let _guard = ENV_LOCK.lock().unwrap(); - let original = env::var(ENV_WORKERS).ok(); - - env::set_var(ENV_WORKERS, "invalid"); - let result = Config::from_env(); - - assert!(matches!( - result, - Err(ConfigError::InvalidWorkerCount { - name: ENV_WORKERS, - value, - .. - }) if value == "invalid" - )); - restore_env_var(ENV_WORKERS, original); - } - - #[test] - fn zero_worker_count_selects_the_automatic_default() { - let _guard = ENV_LOCK.lock().unwrap(); - let original = env::var(ENV_WORKERS).ok(); - - env::set_var(ENV_WORKERS, "0"); - let config = Config::from_env().expect("config loads"); - - assert_eq!(config.workers, default_worker_count()); - restore_env_var(ENV_WORKERS, original); - } - - #[test] - fn explicit_worker_count_is_preserved() { - let _guard = ENV_LOCK.lock().unwrap(); - let original = env::var(ENV_WORKERS).ok(); - - env::set_var(ENV_WORKERS, "3"); - let config = Config::from_env().expect("config loads"); - - assert_eq!(config.workers, 3); - restore_env_var(ENV_WORKERS, original); - } - - #[test] - fn test_parse_presets_single() { - let presets_str = "thumbnail=resize:fit:150:150/quality:80"; - let presets = parse_presets(presets_str).expect("parses"); - assert_eq!(presets.len(), 1); - assert_eq!(presets.get("thumbnail").map(|opts| opts.len()), Some(2)); - } - - #[test] - fn test_parse_presets_multiple() { - let presets_str = "thumbnail=resize:fit:150:150/quality:80,small=resize:fit:300:300/quality:85"; - let presets = parse_presets(presets_str).expect("parses"); - assert_eq!(presets.len(), 2); - assert_eq!(presets.get("thumbnail").map(|opts| opts.len()), Some(2)); - assert_eq!(presets.get("small").map(|opts| opts.len()), Some(2)); - } - - #[test] - fn test_parse_presets_empty() { - let presets_str = ""; - let presets = parse_presets(presets_str).expect("parses"); - assert_eq!(presets.len(), 0); - } - - #[test] - fn test_parse_presets_with_spaces() { - let presets_str = "thumbnail = resize:fit:150:150/quality:80 , small = resize:fit:300:300"; - let presets = parse_presets(presets_str).expect("parses"); - assert_eq!(presets.len(), 2); - assert_eq!(presets.get("thumbnail").map(|opts| opts.len()), Some(2)); - assert_eq!(presets.get("small").map(|opts| opts.len()), Some(1)); - } - - #[test] - fn test_parse_presets_default() { - let presets_str = "default=quality:90/dpr:2"; - let presets = parse_presets(presets_str).expect("parses"); - assert_eq!(presets.len(), 1); - assert_eq!(presets.get("default").map(|opts| opts.len()), Some(2)); - } - - #[test] - fn test_parse_presets_invalid_format() { - let presets_str = "thumbnail:resize:fit:150:150"; - assert!(parse_presets(presets_str).is_err()); - } - - #[test] - fn test_parse_presets_missing_name() { - let presets_str = "=resize:fit:150:150"; - assert!(parse_presets(presets_str).is_err()); - } - - #[test] - fn test_parse_presets_missing_options() { - let presets_str = "thumbnail="; - assert!(parse_presets(presets_str).is_err()); - } - - #[test] - fn test_config_default_format_from_env() { - let _guard = ENV_LOCK.lock().unwrap(); - let original = env::var(ENV_DEFAULT_FORMAT).ok(); - - env::remove_var(ENV_DEFAULT_FORMAT); - let config = Config::from_env().expect("config loads"); - assert_eq!(config.default_format, DefaultOutputFormat::Source); - - env::set_var(ENV_DEFAULT_FORMAT, "JPEG"); - let config = Config::from_env().expect("config loads"); - assert_eq!(config.default_format, DefaultOutputFormat::Jpeg); - - env::set_var(ENV_DEFAULT_FORMAT, "heic"); - let config = Config::from_env().expect("config loads"); - assert_eq!(config.default_format, DefaultOutputFormat::Heif); - - env::set_var(ENV_DEFAULT_FORMAT, "bmp"); - assert!(matches!( - Config::from_env(), - Err(ConfigError::InvalidDefaultFormat { value, .. }) if value == "bmp" - )); - - restore_env_var(ENV_DEFAULT_FORMAT, original); - } - - #[test] - fn default_output_format_normalizes_aliases() { - assert_eq!("jpg".parse(), Ok(DefaultOutputFormat::Jpeg)); - assert_eq!(" HEIC ".parse(), Ok(DefaultOutputFormat::Heif)); - assert_eq!(DefaultOutputFormat::Heif.as_str(), "heif"); - assert!("bmp".parse::().is_err()); - } - - #[test] - fn test_config_presets_from_env() { - let _guard = ENV_LOCK.lock().unwrap(); - let original_presets = env::var(ENV_PRESETS).ok(); - let original_only_presets = env::var(ENV_ONLY_PRESETS).ok(); - - env::set_var(ENV_PRESETS, "thumbnail=resize:fit:150:150,default=quality:90"); - env::set_var(ENV_ONLY_PRESETS, "true"); - - let config = Config::from_env().expect("config loads"); - - assert_eq!(config.presets.len(), 2); - assert_eq!(config.presets.get("thumbnail").map(|opts| opts.len()), Some(1)); - assert_eq!(config.presets.get("default").map(|opts| opts.len()), Some(1)); - assert!(config.only_presets); - - restore_env_var(ENV_PRESETS, original_presets); - restore_env_var(ENV_ONLY_PRESETS, original_only_presets); - } - - #[test] - fn test_config_only_presets_false_by_default() { - let _guard = ENV_LOCK.lock().unwrap(); - let original_only_presets = env::var(ENV_ONLY_PRESETS).ok(); - - env::remove_var(ENV_ONLY_PRESETS); - - let config = Config::from_env().expect("config loads"); - - assert!(!config.only_presets); - - restore_env_var(ENV_ONLY_PRESETS, original_only_presets); - } - - #[test] - fn invalid_max_source_file_size_fails_configuration() { - let _guard = ENV_LOCK.lock().unwrap(); - let original = env::var(ENV_MAX_SRC_FILE_SIZE).ok(); - - env::set_var(ENV_MAX_SRC_FILE_SIZE, "invalid"); - let result = Config::from_env(); - - assert!(matches!( - result, - Err(ConfigError::InvalidSecurityLimit { - name: ENV_MAX_SRC_FILE_SIZE, - .. - }) - )); - restore_env_var(ENV_MAX_SRC_FILE_SIZE, original); - } + config.max_animation_frames = security_limit_var(ENV_MAX_ANIMATION_FRAMES)?; + config.max_animation_frame_resolution = security_limit_var(ENV_MAX_ANIMATION_FRAME_RESOLUTION)?; + + config.option_defaults = OptionDefaults { + auto_rotate: bool_var(ENV_AUTO_ROTATE, true)?, + strip_metadata: bool_var(ENV_STRIP_METADATA, false)?, + keep_copyright: bool_var(ENV_KEEP_COPYRIGHT, false)?, + strip_color_profile: bool_var(ENV_STRIP_COLOR_PROFILE, false)?, + preserve_hdr: bool_var(ENV_PRESERVE_HDR, false)?, + enforce_thumbnail: bool_var(ENV_ENFORCE_THUMBNAIL, false)?, + return_attachment: bool_var(ENV_RETURN_ATTACHMENT, false)?, + quality: parsed_var::(ENV_QUALITY)?.map(|quality| quality.clamp(1, 100)), + }; - #[test] - fn non_finite_max_source_resolution_fails_configuration() { - let _guard = ENV_LOCK.lock().unwrap(); - let original = env::var(ENV_MAX_SRC_RESOLUTION).ok(); - - for value in ["NaN", "inf", "-inf"] { - env::set_var(ENV_MAX_SRC_RESOLUTION, value); - let result = Config::from_env(); - assert!(matches!( - result, - Err(ConfigError::InvalidSecurityLimit { - name: ENV_MAX_SRC_RESOLUTION, - .. - }) - )); - } + config.ttl = parsed_var::(ENV_TTL)?; + config.cache_control_passthrough = bool_var(ENV_CACHE_CONTROL_PASSTHROUGH, false)?; + config.use_etag = bool_var(ENV_USE_ETAG, false)?; + config.last_modified_enabled = bool_var(ENV_LAST_MODIFIED_ENABLED, false)?; + config.set_canonical_header = bool_var(ENV_SET_CANONICAL_HEADER, false)?; + config.allow_origin = optional_var(ENV_ALLOW_ORIGIN)?.filter(|value| !value.trim().is_empty()); + config.path_prefix = normalize_path_prefix(optional_var(ENV_PATH_PREFIX)?.as_deref()); + config.health_check_path = normalize_health_check_path(optional_var(ENV_HEALTH_CHECK_PATH)?.as_deref()); + config.development_errors_mode = bool_var(ENV_DEVELOPMENT_ERRORS_MODE, false)?; + config.enable_debug_headers = bool_var(ENV_ENABLE_DEBUG_HEADERS, false)?; + + config.user_agent = optional_var(ENV_USER_AGENT)? + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()); + config.max_redirects = parsed_var::(ENV_MAX_REDIRECTS)?.unwrap_or(10); + + config.enable_webp_detection = bool_var(ENV_ENABLE_WEBP_DETECTION, false)?; + config.enforce_webp = bool_var(ENV_ENFORCE_WEBP, false)?; + config.enable_avif_detection = bool_var(ENV_ENABLE_AVIF_DETECTION, false)?; + config.enforce_avif = bool_var(ENV_ENFORCE_AVIF, false)?; + config.enable_client_hints = bool_var(ENV_ENABLE_CLIENT_HINTS, false)?; - restore_env_var(ENV_MAX_SRC_RESOLUTION, original); + Ok(config) } +} - #[test] - fn max_result_dimension_is_validated_at_startup() { - let _guard = ENV_LOCK.lock().unwrap(); - let original = env::var(ENV_MAX_RESULT_DIMENSION).ok(); - - env::set_var(ENV_MAX_RESULT_DIMENSION, "8192"); - let config = Config::from_env().expect("valid dimension"); - assert_eq!(config.max_result_dimension.map(MaxResultDimension::get), Some(8192)); - - // A malformed ceiling stops startup rather than silently leaving the - // result size unbounded. - for value in ["0", "-1", "huge"] { - env::set_var(ENV_MAX_RESULT_DIMENSION, value); - assert!(matches!( - Config::from_env(), - Err(ConfigError::InvalidSecurityLimit { - name: ENV_MAX_RESULT_DIMENSION, - .. - }) - )); - } - - restore_env_var(ENV_MAX_RESULT_DIMENSION, original); +/// Normalises a mount prefix to either empty or `/segment` with no trailing +/// slash, so routes can be built by concatenation without doubling separators. +fn normalize_path_prefix(prefix: Option<&str>) -> String { + let Some(prefix) = prefix else { + return String::new(); + }; + let trimmed = prefix.trim().trim_matches('/'); + if trimmed.is_empty() { + String::new() + } else { + format!("/{trimmed}") } +} - #[test] - fn valid_security_limits_are_stored_as_validated_types() { - let _guard = ENV_LOCK.lock().unwrap(); - let original_file_size = env::var(ENV_MAX_SRC_FILE_SIZE).ok(); - let original_resolution = env::var(ENV_MAX_SRC_RESOLUTION).ok(); - - env::set_var(ENV_MAX_SRC_FILE_SIZE, "4096"); - env::set_var(ENV_MAX_SRC_RESOLUTION, "2.5"); - let config = Config::from_env().expect("security limits are valid"); - - assert_eq!(config.max_src_file_size.map(MaxSourceFileSize::get), Some(4096)); - assert_eq!( - config.max_src_resolution.map(MaxSourceResolution::pixels), - Some(2_500_000) - ); - - restore_env_var(ENV_MAX_SRC_FILE_SIZE, original_file_size); - restore_env_var(ENV_MAX_SRC_RESOLUTION, original_resolution); +fn normalize_health_check_path(path: Option<&str>) -> String { + let Some(path) = path else { + return "/health".to_string(); + }; + let trimmed = path.trim().trim_matches('/'); + if trimmed.is_empty() { + "/health".to_string() + } else { + format!("/{trimmed}") } } + +#[cfg(test)] +mod tests; diff --git a/src/config/tests.rs b/src/config/tests.rs new file mode 100644 index 0000000..727617b --- /dev/null +++ b/src/config/tests.rs @@ -0,0 +1,302 @@ +//! Configuration tests. + +use super::*; + +use std::env; +use std::sync::Mutex; + +lazy_static::lazy_static! { + static ref ENV_LOCK: Mutex<()> = Mutex::new(()); +} + +fn restore_env_var(key: &str, original: Option) { + if let Some(value) = original { + env::set_var(key, value); + } else { + env::remove_var(key); + } +} + +#[test] +fn prometheus_numeric_port_maps_to_default_host() { + let _guard = ENV_LOCK.lock().unwrap(); + let original_prometheus = env::var(ENV_PROMETHEUS_BIND).ok(); + + env::set_var(ENV_PROMETHEUS_BIND, "3005"); + let config = Config::from_env().expect("config loads"); + + assert_eq!(config.prometheus_bind_address.as_deref(), Some("0.0.0.0:3005")); + + restore_env_var(ENV_PROMETHEUS_BIND, original_prometheus); +} + +#[test] +fn bind_numeric_port_maps_to_default_host() { + let _guard = ENV_LOCK.lock().unwrap(); + let original_bind = env::var(ENV_BIND).ok(); + let original_prometheus = env::var(ENV_PROMETHEUS_BIND).ok(); + + env::set_var(ENV_BIND, "3456"); + env::remove_var(ENV_PROMETHEUS_BIND); + + let config = Config::from_env().expect("config loads"); + + assert_eq!(config.bind_address, "0.0.0.0:3456"); + assert_eq!(config.prometheus_bind_address, None); + + restore_env_var(ENV_BIND, original_bind); + restore_env_var(ENV_PROMETHEUS_BIND, original_prometheus); +} + +#[test] +fn invalid_worker_count_fails_configuration() { + let _guard = ENV_LOCK.lock().unwrap(); + let original = env::var(ENV_WORKERS).ok(); + + env::set_var(ENV_WORKERS, "invalid"); + let result = Config::from_env(); + + assert!(matches!( + result, + Err(ConfigError::InvalidWorkerCount { + name: ENV_WORKERS, + value, + .. + }) if value == "invalid" + )); + restore_env_var(ENV_WORKERS, original); +} + +#[test] +fn zero_worker_count_selects_the_automatic_default() { + let _guard = ENV_LOCK.lock().unwrap(); + let original = env::var(ENV_WORKERS).ok(); + + env::set_var(ENV_WORKERS, "0"); + let config = Config::from_env().expect("config loads"); + + assert_eq!(config.workers, default_worker_count()); + restore_env_var(ENV_WORKERS, original); +} + +#[test] +fn explicit_worker_count_is_preserved() { + let _guard = ENV_LOCK.lock().unwrap(); + let original = env::var(ENV_WORKERS).ok(); + + env::set_var(ENV_WORKERS, "3"); + let config = Config::from_env().expect("config loads"); + + assert_eq!(config.workers, 3); + restore_env_var(ENV_WORKERS, original); +} + +#[test] +fn test_parse_presets_single() { + let presets_str = "thumbnail=resize:fit:150:150/quality:80"; + let presets = parse_presets(presets_str).expect("parses"); + assert_eq!(presets.len(), 1); + assert_eq!(presets.get("thumbnail").map(|opts| opts.len()), Some(2)); +} + +#[test] +fn test_parse_presets_multiple() { + let presets_str = "thumbnail=resize:fit:150:150/quality:80,small=resize:fit:300:300/quality:85"; + let presets = parse_presets(presets_str).expect("parses"); + assert_eq!(presets.len(), 2); + assert_eq!(presets.get("thumbnail").map(|opts| opts.len()), Some(2)); + assert_eq!(presets.get("small").map(|opts| opts.len()), Some(2)); +} + +#[test] +fn test_parse_presets_empty() { + let presets_str = ""; + let presets = parse_presets(presets_str).expect("parses"); + assert_eq!(presets.len(), 0); +} + +#[test] +fn test_parse_presets_with_spaces() { + let presets_str = "thumbnail = resize:fit:150:150/quality:80 , small = resize:fit:300:300"; + let presets = parse_presets(presets_str).expect("parses"); + assert_eq!(presets.len(), 2); + assert_eq!(presets.get("thumbnail").map(|opts| opts.len()), Some(2)); + assert_eq!(presets.get("small").map(|opts| opts.len()), Some(1)); +} + +#[test] +fn test_parse_presets_default() { + let presets_str = "default=quality:90/dpr:2"; + let presets = parse_presets(presets_str).expect("parses"); + assert_eq!(presets.len(), 1); + assert_eq!(presets.get("default").map(|opts| opts.len()), Some(2)); +} + +#[test] +fn test_parse_presets_invalid_format() { + let presets_str = "thumbnail:resize:fit:150:150"; + assert!(parse_presets(presets_str).is_err()); +} + +#[test] +fn test_parse_presets_missing_name() { + let presets_str = "=resize:fit:150:150"; + assert!(parse_presets(presets_str).is_err()); +} + +#[test] +fn test_parse_presets_missing_options() { + let presets_str = "thumbnail="; + assert!(parse_presets(presets_str).is_err()); +} + +#[test] +fn test_config_default_format_from_env() { + let _guard = ENV_LOCK.lock().unwrap(); + let original = env::var(ENV_DEFAULT_FORMAT).ok(); + + env::remove_var(ENV_DEFAULT_FORMAT); + let config = Config::from_env().expect("config loads"); + assert_eq!(config.default_format, DefaultOutputFormat::Source); + + env::set_var(ENV_DEFAULT_FORMAT, "JPEG"); + let config = Config::from_env().expect("config loads"); + assert_eq!(config.default_format, DefaultOutputFormat::Jpeg); + + env::set_var(ENV_DEFAULT_FORMAT, "heic"); + let config = Config::from_env().expect("config loads"); + assert_eq!(config.default_format, DefaultOutputFormat::Heif); + + env::set_var(ENV_DEFAULT_FORMAT, "bmp"); + assert!(matches!( + Config::from_env(), + Err(ConfigError::InvalidDefaultFormat { value, .. }) if value == "bmp" + )); + + restore_env_var(ENV_DEFAULT_FORMAT, original); +} + +#[test] +fn default_output_format_normalizes_aliases() { + assert_eq!("jpg".parse(), Ok(DefaultOutputFormat::Jpeg)); + assert_eq!(" HEIC ".parse(), Ok(DefaultOutputFormat::Heif)); + assert_eq!(DefaultOutputFormat::Heif.as_str(), "heif"); + assert!("bmp".parse::().is_err()); +} + +#[test] +fn test_config_presets_from_env() { + let _guard = ENV_LOCK.lock().unwrap(); + let original_presets = env::var(ENV_PRESETS).ok(); + let original_only_presets = env::var(ENV_ONLY_PRESETS).ok(); + + env::set_var(ENV_PRESETS, "thumbnail=resize:fit:150:150,default=quality:90"); + env::set_var(ENV_ONLY_PRESETS, "true"); + + let config = Config::from_env().expect("config loads"); + + assert_eq!(config.presets.len(), 2); + assert_eq!(config.presets.get("thumbnail").map(|opts| opts.len()), Some(1)); + assert_eq!(config.presets.get("default").map(|opts| opts.len()), Some(1)); + assert!(config.only_presets); + + restore_env_var(ENV_PRESETS, original_presets); + restore_env_var(ENV_ONLY_PRESETS, original_only_presets); +} + +#[test] +fn test_config_only_presets_false_by_default() { + let _guard = ENV_LOCK.lock().unwrap(); + let original_only_presets = env::var(ENV_ONLY_PRESETS).ok(); + + env::remove_var(ENV_ONLY_PRESETS); + + let config = Config::from_env().expect("config loads"); + + assert!(!config.only_presets); + + restore_env_var(ENV_ONLY_PRESETS, original_only_presets); +} + +#[test] +fn invalid_max_source_file_size_fails_configuration() { + let _guard = ENV_LOCK.lock().unwrap(); + let original = env::var(ENV_MAX_SRC_FILE_SIZE).ok(); + + env::set_var(ENV_MAX_SRC_FILE_SIZE, "invalid"); + let result = Config::from_env(); + + assert!(matches!( + result, + Err(ConfigError::InvalidSecurityLimit { + name: ENV_MAX_SRC_FILE_SIZE, + .. + }) + )); + restore_env_var(ENV_MAX_SRC_FILE_SIZE, original); +} + +#[test] +fn non_finite_max_source_resolution_fails_configuration() { + let _guard = ENV_LOCK.lock().unwrap(); + let original = env::var(ENV_MAX_SRC_RESOLUTION).ok(); + + for value in ["NaN", "inf", "-inf"] { + env::set_var(ENV_MAX_SRC_RESOLUTION, value); + let result = Config::from_env(); + assert!(matches!( + result, + Err(ConfigError::InvalidSecurityLimit { + name: ENV_MAX_SRC_RESOLUTION, + .. + }) + )); + } + + restore_env_var(ENV_MAX_SRC_RESOLUTION, original); +} + +#[test] +fn max_result_dimension_is_validated_at_startup() { + let _guard = ENV_LOCK.lock().unwrap(); + let original = env::var(ENV_MAX_RESULT_DIMENSION).ok(); + + env::set_var(ENV_MAX_RESULT_DIMENSION, "8192"); + let config = Config::from_env().expect("valid dimension"); + assert_eq!(config.max_result_dimension.map(MaxResultDimension::get), Some(8192)); + + // A malformed ceiling stops startup rather than silently leaving the + // result size unbounded. + for value in ["0", "-1", "huge"] { + env::set_var(ENV_MAX_RESULT_DIMENSION, value); + assert!(matches!( + Config::from_env(), + Err(ConfigError::InvalidSecurityLimit { + name: ENV_MAX_RESULT_DIMENSION, + .. + }) + )); + } + + restore_env_var(ENV_MAX_RESULT_DIMENSION, original); +} + +#[test] +fn valid_security_limits_are_stored_as_validated_types() { + let _guard = ENV_LOCK.lock().unwrap(); + let original_file_size = env::var(ENV_MAX_SRC_FILE_SIZE).ok(); + let original_resolution = env::var(ENV_MAX_SRC_RESOLUTION).ok(); + + env::set_var(ENV_MAX_SRC_FILE_SIZE, "4096"); + env::set_var(ENV_MAX_SRC_RESOLUTION, "2.5"); + let config = Config::from_env().expect("security limits are valid"); + + assert_eq!(config.max_src_file_size.map(MaxSourceFileSize::get), Some(4096)); + assert_eq!( + config.max_src_resolution.map(MaxSourceResolution::pixels), + Some(2_500_000) + ); + + restore_env_var(ENV_MAX_SRC_FILE_SIZE, original_file_size); + restore_env_var(ENV_MAX_SRC_RESOLUTION, original_resolution); +} diff --git a/src/constants.rs b/src/constants.rs index e64a687..838b3ee 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -8,6 +8,10 @@ pub const ENV_ALLOWED_MIME_TYPES: &str = "IMGFORGE_ALLOWED_MIME_TYPES"; pub const ENV_MAX_SRC_RESOLUTION: &str = "IMGFORGE_MAX_SRC_RESOLUTION"; /// Ceiling for either dimension of the processed image. pub const ENV_MAX_RESULT_DIMENSION: &str = "IMGFORGE_MAX_RESULT_DIMENSION"; +/// Ceiling on how many frames of an animated source are decoded. +pub const ENV_MAX_ANIMATION_FRAMES: &str = "IMGFORGE_MAX_ANIMATION_FRAMES"; +/// Ceiling on the pixel count of a single animation frame, in megapixels. +pub const ENV_MAX_ANIMATION_FRAME_RESOLUTION: &str = "IMGFORGE_MAX_ANIMATION_FRAME_RESOLUTION"; pub const ENV_ALLOW_SECURITY_OPTIONS: &str = "IMGFORGE_ALLOW_SECURITY_OPTIONS"; pub const ENV_WORKERS: &str = "IMGFORGE_WORKERS"; @@ -24,3 +28,39 @@ pub const ENV_DOWNLOAD_TIMEOUT: &str = "IMGFORGE_DOWNLOAD_TIMEOUT"; pub const ENV_RATE_LIMIT_PER_MINUTE: &str = "IMGFORGE_RATE_LIMIT_PER_MINUTE"; pub const ENV_PRESETS: &str = "IMGFORGE_PRESETS"; pub const ENV_ONLY_PRESETS: &str = "IMGFORGE_ONLY_PRESETS"; + +// Processing defaults a URL may override. +pub const ENV_AUTO_ROTATE: &str = "IMGFORGE_AUTO_ROTATE"; +pub const ENV_STRIP_METADATA: &str = "IMGFORGE_STRIP_METADATA"; +pub const ENV_KEEP_COPYRIGHT: &str = "IMGFORGE_KEEP_COPYRIGHT"; +pub const ENV_STRIP_COLOR_PROFILE: &str = "IMGFORGE_STRIP_COLOR_PROFILE"; +pub const ENV_PRESERVE_HDR: &str = "IMGFORGE_PRESERVE_HDR"; +pub const ENV_ENFORCE_THUMBNAIL: &str = "IMGFORGE_ENFORCE_THUMBNAIL"; +pub const ENV_RETURN_ATTACHMENT: &str = "IMGFORGE_RETURN_ATTACHMENT"; +pub const ENV_QUALITY: &str = "IMGFORGE_QUALITY"; + +// Response delivery. +pub const ENV_TTL: &str = "IMGFORGE_TTL"; +pub const ENV_CACHE_CONTROL_PASSTHROUGH: &str = "IMGFORGE_CACHE_CONTROL_PASSTHROUGH"; +pub const ENV_USE_ETAG: &str = "IMGFORGE_USE_ETAG"; +pub const ENV_LAST_MODIFIED_ENABLED: &str = "IMGFORGE_LAST_MODIFIED_ENABLED"; +pub const ENV_SET_CANONICAL_HEADER: &str = "IMGFORGE_SET_CANONICAL_HEADER"; +pub const ENV_ALLOW_ORIGIN: &str = "IMGFORGE_ALLOW_ORIGIN"; +pub const ENV_PATH_PREFIX: &str = "IMGFORGE_PATH_PREFIX"; +pub const ENV_HEALTH_CHECK_PATH: &str = "IMGFORGE_HEALTH_CHECK_PATH"; +pub const ENV_DEVELOPMENT_ERRORS_MODE: &str = "IMGFORGE_DEVELOPMENT_ERRORS_MODE"; +pub const ENV_ENABLE_DEBUG_HEADERS: &str = "IMGFORGE_ENABLE_DEBUG_HEADERS"; + +// Source resolution. +pub const ENV_USER_AGENT: &str = "IMGFORGE_USER_AGENT"; +pub const ENV_MAX_REDIRECTS: &str = "IMGFORGE_MAX_REDIRECTS"; + +// Content negotiation. +pub const ENV_ENABLE_WEBP_DETECTION: &str = "IMGFORGE_ENABLE_WEBP_DETECTION"; +pub const ENV_ENFORCE_WEBP: &str = "IMGFORGE_ENFORCE_WEBP"; +pub const ENV_ENABLE_AVIF_DETECTION: &str = "IMGFORGE_ENABLE_AVIF_DETECTION"; +pub const ENV_ENFORCE_AVIF: &str = "IMGFORGE_ENFORCE_AVIF"; +pub const ENV_ENABLE_CLIENT_HINTS: &str = "IMGFORGE_ENABLE_CLIENT_HINTS"; + +/// Default `User-Agent` sent when fetching a source image. +pub const DEFAULT_USER_AGENT: &str = concat!("imgforge/", env!("CARGO_PKG_VERSION")); diff --git a/src/fetch.rs b/src/fetch.rs index a0a550e..9dbb830 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -15,6 +15,24 @@ pub enum FetchError { ResponseBody(#[source] reqwest::Error), #[error("source image exceeds the maximum allowed size of {limit} bytes")] SourceTooLarge { limit: usize, actual: Option }, + #[error("source URL is not allowed")] + SourceNotAllowed, + #[error("source responded with status {status}")] + UpstreamStatus { status: u16 }, +} + +/// A source image and the response headers worth carrying forward. +/// +/// The caching headers are kept so the proxy can pass an upstream `Cache-Control` +/// or `Last-Modified` through to its own clients, which is the difference +/// between a CDN honouring the origin's policy and inventing one. +#[derive(Debug, Clone, Default)] +pub struct FetchedImage { + pub bytes: Bytes, + pub content_type: Option, + pub cache_control: Option, + pub last_modified: Option, + pub etag: Option, } fn record_fetch_metrics(fetch_start: std::time::Instant, status: &str) { @@ -33,29 +51,41 @@ fn initial_buffer_capacity(content_length: Option, max_bytes: Option Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()) +} + /// Fetches an image from a given URL using the provided HTTP client. pub async fn fetch_image( client: &reqwest::Client, url: &str, max_bytes: Option, -) -> Result<(Bytes, Option), FetchError> { +) -> Result { let fetch_start = std::time::Instant::now(); let mut response = client.get(url).send().await.map_err(|source| { record_fetch_metrics(fetch_start, "error"); FetchError::Request(source) })?; - let fetch_status = if response.status().is_success() { - "success" - } else { - "error" - }; - - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|ct| ct.to_str().ok()) - .map(|ct| ct.to_string()); + + // An error page is not an image. Returning its bytes meant the failure + // surfaced later as "failed to decode source image", which told the caller + // nothing about the 404 that actually happened. + if !response.status().is_success() { + record_fetch_metrics(fetch_start, "error"); + return Err(FetchError::UpstreamStatus { + status: response.status().as_u16(), + }); + } + + let headers = response.headers().clone(); + let content_type = header_string(&headers, header::CONTENT_TYPE); + let cache_control = header_string(&headers, header::CACHE_CONTROL); + let last_modified = header_string(&headers, header::LAST_MODIFIED); + let etag = header_string(&headers, header::ETAG); let advertised_length = response.content_length().map(|len| len as usize); if let (Some(limit), Some(len)) = (max_bytes, advertised_length) { @@ -94,8 +124,14 @@ pub async fn fetch_image( } } - record_fetch_metrics(fetch_start, fetch_status); - Ok((image_bytes.freeze(), content_type)) + record_fetch_metrics(fetch_start, "success"); + Ok(FetchedImage { + bytes: image_bytes.freeze(), + content_type, + cache_control, + last_modified, + etag, + }) } #[cfg(test)] @@ -134,35 +170,39 @@ mod tests { .respond_with( ResponseTemplate::new(200) .set_body_bytes(vec![1u8, 2, 3]) - .insert_header("Content-Type", "image/jpeg"), + .insert_header("Content-Type", "image/jpeg") + .insert_header("Cache-Control", "max-age=600") + .insert_header("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT"), ) .mount(&server) .await; let client = client_with_timeout(Duration::from_secs(5)); - let (bytes, content_type) = fetch_image(&client, &format!("{}/image.jpg", server.uri()), None) + let fetched = fetch_image(&client, &format!("{}/image.jpg", server.uri()), None) .await .expect("request should succeed"); - assert_eq!(bytes.len(), 3); - assert_eq!(content_type.as_deref(), Some("image/jpeg")); + assert_eq!(fetched.bytes.len(), 3); + assert_eq!(fetched.content_type.as_deref(), Some("image/jpeg")); + assert_eq!(fetched.cache_control.as_deref(), Some("max-age=600")); + assert_eq!(fetched.last_modified.as_deref(), Some("Wed, 21 Oct 2015 07:28:00 GMT")); } #[tokio::test] - async fn test_fetch_image_404() { + async fn test_fetch_image_404_is_reported_as_an_upstream_status() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/missing.jpg")) - .respond_with(ResponseTemplate::new(404).set_body_bytes(Vec::::new())) + .respond_with(ResponseTemplate::new(404).set_body_string("nope")) .mount(&server) .await; let client = client_with_timeout(Duration::from_secs(5)); - let (bytes, _) = fetch_image(&client, &format!("{}/missing.jpg", server.uri()), None) - .await - .expect("404 responses should still return bytes"); + let result = fetch_image(&client, &format!("{}/missing.jpg", server.uri()), None).await; - assert_eq!(bytes.len(), 0); + // Returning the error page's bytes would have surfaced as "failed to + // decode source image", hiding the 404 behind a decoder complaint. + assert!(matches!(result, Err(FetchError::UpstreamStatus { status: 404 }))); } #[tokio::test] @@ -198,12 +238,12 @@ mod tests { .await; let client = client_with_timeout(Duration::from_secs(5)); - let (bytes, content_type) = fetch_image(&client, &format!("{}/image.png", server.uri()), None) + let fetched = fetch_image(&client, &format!("{}/image.png", server.uri()), None) .await .expect("request should succeed"); - assert_eq!(bytes.len(), 3); - assert_eq!(content_type.as_deref(), Some("image/png")); + assert_eq!(fetched.bytes.len(), 3); + assert_eq!(fetched.content_type.as_deref(), Some("image/png")); } #[tokio::test] diff --git a/src/handlers.rs b/src/handlers.rs index a685755..6dd55dc 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -41,6 +41,7 @@ pub async fn info_handler( "channels": info.channels, "has_alpha": info.has_alpha, "orientation": info.orientation, + "pages": info.pages, }); (StatusCode::OK, Json(response)).into_response() } diff --git a/src/limits.rs b/src/limits.rs index 65ad39f..acaeded 100644 --- a/src/limits.rs +++ b/src/limits.rs @@ -121,10 +121,86 @@ impl FromStr for MaxResultDimension { } } +/// Validated ceiling on how many frames of an animated source are decoded. +/// +/// An animation multiplies every cost by its frame count, so the source limits +/// that bound a still image bound almost nothing here: a 1000-frame GIF well +/// under the resolution ceiling still asks for a thousand times the work. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct MaxAnimationFrames(NonZeroU32); + +impl MaxAnimationFrames { + /// Construct a limit from a positive frame count. + pub fn new(frames: u32) -> Result { + NonZeroU32::new(frames) + .map(Self) + .ok_or(SecurityLimitError::ZeroDimension) + } + + /// Return the maximum allowed frame count. + pub fn get(self) -> u32 { + self.0.get() + } +} + +impl FromStr for MaxAnimationFrames { + type Err = SecurityLimitError; + + fn from_str(value: &str) -> Result { + let frames = value.parse::().map_err(SecurityLimitError::InvalidDimension)?; + Self::new(frames) + } +} + +/// Validated ceiling on the pixel count of a single animation frame. +/// +/// Expressed in megapixels, like [`MaxSourceResolution`], because that is what +/// imgproxy's `max_animation_frame_resolution` takes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct MaxAnimationFrameResolution(NonZeroU64); + +impl MaxAnimationFrameResolution { + /// Construct a limit from a finite, positive megapixel value. + pub fn from_megapixels(megapixels: f64) -> Result { + MaxSourceResolution::from_megapixels(megapixels).map(|limit| Self(limit.0)) + } + + /// Return the maximum allowed pixel count per frame. + pub fn pixels(self) -> u64 { + self.0.get() + } +} + +impl FromStr for MaxAnimationFrameResolution { + type Err = SecurityLimitError; + + fn from_str(value: &str) -> Result { + let megapixels = value.parse::().map_err(SecurityLimitError::InvalidResolution)?; + Self::from_megapixels(megapixels) + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn animation_limits_reject_zero_and_nonsense() { + for value in ["0", "-1", "abc", ""] { + assert!(value.parse::().is_err(), "accepted {value}"); + assert!( + value.parse::().is_err(), + "accepted {value}" + ); + } + + assert_eq!("64".parse::().unwrap().get(), 64); + assert_eq!( + "1.5".parse::().unwrap().pixels(), + 1_500_000 + ); + } + #[test] fn resolution_rejects_non_finite_and_non_positive_values() { for value in ["NaN", "inf", "-inf", "0", "-1"] { diff --git a/src/processing/animation.rs b/src/processing/animation.rs new file mode 100644 index 0000000..2d7fb07 --- /dev/null +++ b/src/processing/animation.rs @@ -0,0 +1,172 @@ +//! Multi-page and animated sources. +//! +//! libvips represents an animation as a single tall image — every frame stacked +//! vertically — with a `page-height` property saying where one frame ends and +//! the next begins. Operations that change the geometry do not update that +//! property, so scaling the stack as one image silently reinterprets four +//! 80px frames as two 160px ones. +//! +//! The way through is to take the stack apart, run each frame through the +//! ordinary pipeline, put it back together, and tell the encoder the new frame +//! height. That keeps every transformation — including the ones that rotate or +//! pad, which no amount of metadata fixing would survive — working on frames +//! rather than on a strip that happens to contain them. + +use crate::processing::options::ParsedOptions; +use crate::processing::transform::{vips, TransformError}; +use libvips::{ops, VipsImage}; +use tracing::debug; + +/// Formats whose loaders accept `page` and `n`. +/// +/// Naming a property a loader does not have makes libvips reject the entire +/// call, so this list is what separates a working request from a source that +/// suddenly fails to open at all. +pub fn supports_pages(format: &str) -> bool { + matches!(format, "gif" | "webp" | "heif" | "avif" | "tiff" | "pdf") +} + +/// Formats that can carry more than one frame in the output. +pub fn supports_animation(format: &str) -> bool { + matches!(format, "gif" | "webp" | "avif" | "heif") +} + +/// What to ask the loader for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LoadPlan { + /// First page to read. + pub page: u32, + /// How many pages to read. `None` means "all of them". + pub count: Option, +} + +impl LoadPlan { + /// Works out which pages a request needs from a source of this format. + /// + /// Returns `None` when the defaults are wanted, so the common case opens + /// the source with no loader options at all. + pub fn resolve(options: &ParsedOptions, source_format: Option<&str>, output_format: &str) -> Option { + let source_format = source_format?; + if !supports_pages(source_format) { + return None; + } + + let page = options.page.unwrap_or(0); + + // An explicit page count wins. Otherwise an animation is read whole + // when the result can hold it, and collapsed to its first frame when it + // cannot — decoding frames that are about to be discarded is pure cost. + // `disable_animation` is defined as collapsing the source to a single + // frame, so it outranks an explicit page count rather than losing to it. + // Letting `pages` win meant `pages:5/disable_animation:true` loaded five + // frames and produced an animation from a request that had asked, in as + // many words, for it not to be one. The starting `page` is still + // honoured: which frame is a separate question from how many. + let count = match options.pages { + _ if options.disable_animation => Some(1), + Some(pages) => Some(pages), + None if supports_animation(output_format) => None, + None => Some(1), + }; + + // A limit only matters once it is below what was going to be read. + let count = match (count, options.max_animation_frames) { + (Some(count), Some(limit)) => Some(count.min(limit.get())), + (None, Some(limit)) => Some(limit.get()), + (count, None) => count, + }; + + if page == 0 && count == Some(1) && options.max_animation_frames.is_none() { + // What the loader would have done anyway. + return None; + } + + Some(Self { page, count }) + } + + /// Renders the plan as a libvips loader option string. + pub fn as_load_options(&self) -> String { + match self.count { + Some(count) => format!("page={},n={}", self.page, count), + None => format!("page={},n=-1", self.page), + } + } +} + +/// An animated image taken apart into its frames. +pub struct Frames { + pub images: Vec, +} + +/// How many frames an opened image holds, and how tall each one is. +/// +/// Returns `None` for a still image, including one whose header claims several +/// pages but whose height does not divide into them — a stack imgforge cannot +/// take apart safely is better treated as the single image it looks like. +pub fn frame_geometry(img: &VipsImage) -> Option<(i32, i32)> { + let pages = img.get_n_pages(); + let page_height = img.get_page_height(); + let height = img.get_height(); + + if pages <= 1 || page_height <= 0 || height <= 0 { + return None; + } + if page_height.checked_mul(pages) != Some(height) { + debug!( + "Ignoring animation: {} pages of {}px do not fill {}px", + pages, page_height, height + ); + return None; + } + + Some((pages, page_height)) +} + +/// Splits an animated image into independent frames. +pub fn split(img: &VipsImage) -> Result { + let Some((pages, page_height)) = frame_geometry(img) else { + return Ok(Frames { + images: vec![ops::copy(img).map_err(vips("Error copying frame"))?], + }); + }; + + let width = img.get_width(); + let images = (0..pages) + .map(|page| { + ops::extract_area(img, 0, page * page_height, width, page_height) + .map_err(vips("Error extracting animation frame")) + }) + .collect::, _>>()?; + + debug!("Split animation into {} frames of {}px", pages, page_height); + Ok(Frames { images }) +} + +/// Stacks processed frames back into a single image. +/// +/// Returns the joined image and the height of one frame, which the encoder +/// needs in order to cut the stack up again. +pub fn join(mut frames: Vec) -> Result<(VipsImage, Option), TransformError> { + if frames.len() <= 1 { + let single = frames + .pop() + .ok_or_else(|| TransformError::invalid("animation", "processing produced no frames"))?; + return Ok((single, None)); + } + + let frame_height = frames[0].get_height(); + if frames.iter().any(|frame| frame.get_height() != frame_height) { + return Err(TransformError::invalid( + "animation", + "animation frames came out of processing at different heights", + )); + } + + let options = ops::ArrayjoinOptions { + across: 1, + ..Default::default() + }; + let joined = ops::arrayjoin_with_opts(&mut frames, &options).map_err(vips("Error joining animation frames"))?; + + Ok((joined, Some(frame_height))) +} diff --git a/src/processing/colorspace.rs b/src/processing/colorspace.rs new file mode 100644 index 0000000..e9b32bd --- /dev/null +++ b/src/processing/colorspace.rs @@ -0,0 +1,127 @@ +//! Colour management around the pipeline. +//! +//! imgforge used to process in whatever colourspace the source arrived in, +//! which is wrong in two ways. A CMYK source has no meaningful red, green and +//! blue channels, so every operation that weights them — saturation, the +//! background flatten, the trim's luminance fallback — produced nonsense. And +//! a source tagged with a wide-gamut ICC profile had its numbers treated as if +//! they were sRGB, so the result came out over-saturated once a viewer applied +//! the profile that was no longer there. +//! +//! Both are fixed by converting into a known space before processing and back +//! out at the end, which is what imgproxy's `colorspaceToProcessing` and +//! `colorspaceToResult` do. + +use crate::processing::transform::{vips, TransformError}; +use libvips::{ops, VipsImage}; +use tracing::debug; + +/// The colourspace a frame is processed in. +/// +/// 16-bit sources keep their depth when the output format can carry it and the +/// request asked to preserve it; everything else lands in 8-bit sRGB, which any +/// encoder can represent. +fn processing_interpretation(img: &VipsImage, keep_high_bit_depth: bool) -> ops::Interpretation { + let Ok(interpretation) = img.guess_interpretation() else { + return ops::Interpretation::Srgb; + }; + + match interpretation { + // Already somewhere the pipeline understands. + ops::Interpretation::Srgb | ops::Interpretation::Rgb | ops::Interpretation::BW => interpretation, + ops::Interpretation::Rgb16 if keep_high_bit_depth => interpretation, + ops::Interpretation::Grey16 if keep_high_bit_depth => interpretation, + ops::Interpretation::Rgb16 => ops::Interpretation::Srgb, + ops::Interpretation::Grey16 => ops::Interpretation::BW, + // CMYK, Lab, HSV, and the rest: sRGB can be produced from any of them. + _ if keep_high_bit_depth => ops::Interpretation::Rgb16, + _ => ops::Interpretation::Srgb, + } +} + +/// Converts a frame into the colourspace the pipeline works in. +/// +/// When the source carries an embedded ICC profile and is not already in a +/// standard space, the conversion goes through that profile so the numbers mean +/// what the profile says they mean. A source without a usable profile falls +/// back to a plain colourspace conversion — `icc_transform` fails rather than +/// guessing, and a failure there is not a reason to fail the request. +pub fn to_processing(img: VipsImage, keep_high_bit_depth: bool) -> Result { + let target = processing_interpretation(&img, keep_high_bit_depth); + let current = img.get_interpretation().unwrap_or(ops::Interpretation::Error); + + // The interpretation enum is not evidence about colour. libvips derives it + // from the band count and format, so every 8-bit three-band image reports as + // `Srgb` — a Display-P3 or Adobe RGB JPEG included, with its real space + // recorded only in the embedded profile. Deciding on the enum alone let + // wide-gamut sources through untransformed and their numbers were then read + // as sRGB: visibly oversaturated output from a file that says exactly what + // it is. + // + // The profile cannot be detected through this crate — `VipsImage::ctx` is + // private, so `vips_image_get_typeof` is out of reach — so the transform is + // attempted and a failure is taken as "no usable profile". That is also what + // imgproxy does: it imports whenever a profile is present rather than + // consulting the interpretation, and pays the same conversion on an image + // whose profile is already sRGB. + let options = ops::IccTransformOptions { + embedded: true, + intent: ops::Intent::Relative, + ..Default::default() + }; + match ops::icc_transform_with_opts(&img, "srgb", &options) { + Ok(transformed) => { + debug!("Converted {:?} to sRGB through its embedded ICC profile", current); + // The transform lands in sRGB; a 16-bit target still needs the final + // hop, and for an 8-bit target this is a no-op. + return convert_colourspace(transformed, target); + } + Err(err) => { + debug!("No usable ICC profile ({}); converting {:?} directly", err, current); + } + } + + // Without a profile the enum is all there is, and an image already in the + // target space needs nothing done to it. + if same_space(current, target) { + return Ok(img); + } + + convert_colourspace(img, target) +} + +/// Prepares a frame for the encoder. +/// +/// Everything reaching this point is already in a standard space, so there is +/// nothing left to convert — the remaining question is only whether the profile +/// that came in should still be attached, and that is settled by the `keep` +/// flags handed to the encoder. This exists so the decision has one home, and +/// so a caller reading the pipeline sees where the round trip closes. +pub fn to_result(img: VipsImage, target_supports_profile: bool) -> Result { + if target_supports_profile { + return Ok(img); + } + + let current = img.get_interpretation().unwrap_or(ops::Interpretation::Error); + if matches!(current, ops::Interpretation::Srgb | ops::Interpretation::BW) { + return Ok(img); + } + + convert_colourspace(img, ops::Interpretation::Srgb) +} + +/// `Interpretation` carries no `PartialEq`, so comparisons go through the +/// discriminant the enum is generated from. +fn same_space(left: ops::Interpretation, right: ops::Interpretation) -> bool { + left as i32 == right as i32 +} + +fn convert_colourspace(img: VipsImage, target: ops::Interpretation) -> Result { + if img + .get_interpretation() + .is_ok_and(|current| same_space(current, target)) + { + return Ok(img); + } + ops::colourspace(&img, target).map_err(vips("Error converting colourspace")) +} diff --git a/src/processing/metadata.rs b/src/processing/metadata.rs new file mode 100644 index 0000000..0528d47 --- /dev/null +++ b/src/processing/metadata.rs @@ -0,0 +1,270 @@ +//! Reading and re-attaching source metadata. +//! +//! Two options need more than the encoder's `keep` flags can express. +//! `enforce_thumbnail` needs the EXIF thumbnail pulled out of the source before +//! anything is decoded, and `keep_copyright` needs one field carried across a +//! strip that libvips can only perform wholesale — its `keep` flags are +//! `none|exif|xmp|iptc|icc|other|gainmap|all`, with no copyright granularity. + +use exif::{In, Tag, Value}; +use tracing::debug; + +/// JPEG marker bytes. +const MARKER_PREFIX: u8 = 0xFF; +const MARKER_SOI: u8 = 0xD8; +const MARKER_APP1: u8 = 0xE1; +const MARKER_SOS: u8 = 0xDA; + +/// The identifier that opens the Exif payload of an APP1 segment. +const EXIF_IDENTIFIER: &[u8] = b"Exif\0\0"; + +/// EXIF tag numbers, as they appear in an IFD entry. +const TAG_COPYRIGHT: u16 = 0x8298; +const TAG_ARTIST: u16 = 0x013B; + +/// EXIF field type for a NUL-terminated ASCII string. +const TYPE_ASCII: u16 = 2; + +/// A copyright statement recovered from a source image. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Copyright { + pub copyright: Option, + pub artist: Option, +} + +impl Copyright { + pub fn is_empty(&self) -> bool { + self.copyright.is_none() && self.artist.is_none() + } +} + +/// Locates the Exif TIFF block inside a JPEG's APP1 segment. +/// +/// Returned as a slice of the input so nothing is copied for the common case of +/// a source that has no copyright to preserve. +fn jpeg_exif_block(image_bytes: &[u8]) -> Option<&[u8]> { + if image_bytes.len() < 4 || image_bytes[0] != MARKER_PREFIX || image_bytes[1] != MARKER_SOI { + return None; + } + + let mut offset = 2; + while offset + 4 <= image_bytes.len() { + if image_bytes[offset] != MARKER_PREFIX { + return None; + } + let marker = image_bytes[offset + 1]; + // Scan data starts here; every segment worth reading is behind us. + if marker == MARKER_SOS { + return None; + } + + let length = usize::from(u16::from_be_bytes([image_bytes[offset + 2], image_bytes[offset + 3]])); + if length < 2 { + return None; + } + let payload_start = offset + 4; + let payload_end = payload_start.checked_add(length - 2)?; + if payload_end > image_bytes.len() { + return None; + } + + if marker == MARKER_APP1 && image_bytes[payload_start..payload_end].starts_with(EXIF_IDENTIFIER) { + return Some(&image_bytes[payload_start + EXIF_IDENTIFIER.len()..payload_end]); + } + + offset = payload_end; + } + + None +} + +fn ascii_field(exif: &exif::Exif, tag: Tag) -> Option { + let field = exif.get_field(tag, In::PRIMARY)?; + match &field.value { + Value::Ascii(values) => { + let text: String = values + .iter() + .flat_map(|bytes| String::from_utf8_lossy(bytes).into_owned().chars().collect::>()) + .collect(); + let trimmed = text.trim_matches(char::from(0)).trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + _ => None, + } +} + +/// Reads the copyright statement a source carries, if any. +pub fn read_copyright(image_bytes: &[u8]) -> Copyright { + let Ok(exif) = exif::Reader::new().read_from_container(&mut std::io::Cursor::new(image_bytes)) else { + return Copyright::default(); + }; + + Copyright { + copyright: ascii_field(&exif, Tag::Copyright), + artist: ascii_field(&exif, Tag::Artist), + } +} + +/// Builds a minimal little-endian Exif APP1 payload carrying only the fields in +/// `copyright`. +fn build_exif_payload(copyright: &Copyright) -> Option> { + let mut entries: Vec<(u16, Vec)> = Vec::new(); + if let Some(value) = copyright.copyright.as_deref() { + entries.push((TAG_COPYRIGHT, nul_terminated(value))); + } + if let Some(value) = copyright.artist.as_deref() { + entries.push((TAG_ARTIST, nul_terminated(value))); + } + if entries.is_empty() { + return None; + } + + // IFD0 sits at offset 8, immediately after the TIFF header. Values longer + // than the four bytes an entry can hold inline are appended after the + // directory and referenced by offset. + let entry_count = entries.len(); + let directory_end = 8 + 2 + entry_count * 12 + 4; + let mut values: Vec = Vec::new(); + let mut directory: Vec = Vec::new(); + + directory.extend_from_slice(&(entry_count as u16).to_le_bytes()); + for (tag, value) in &entries { + directory.extend_from_slice(&tag.to_le_bytes()); + directory.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + directory.extend_from_slice(&(value.len() as u32).to_le_bytes()); + + if value.len() <= 4 { + let mut inline = [0u8; 4]; + inline[..value.len()].copy_from_slice(value); + directory.extend_from_slice(&inline); + } else { + let offset = u32::try_from(directory_end + values.len()).ok()?; + directory.extend_from_slice(&offset.to_le_bytes()); + values.extend_from_slice(value); + // Keep every value on an even boundary, as the TIFF spec requires. + if values.len() % 2 == 1 { + values.push(0); + } + } + } + // No IFD1: the thumbnail, if there was one, went with the strip. + directory.extend_from_slice(&0u32.to_le_bytes()); + + let mut payload = Vec::from(EXIF_IDENTIFIER); + payload.extend_from_slice(b"II"); + payload.extend_from_slice(&42u16.to_le_bytes()); + payload.extend_from_slice(&8u32.to_le_bytes()); + payload.extend_from_slice(&directory); + payload.extend_from_slice(&values); + + // An APP1 segment carries its own length in two bytes, including those two. + (payload.len() + 2 <= usize::from(u16::MAX)).then_some(payload) +} + +fn nul_terminated(value: &str) -> Vec { + let mut bytes = value.as_bytes().to_vec(); + bytes.push(0); + bytes +} + +/// Re-attaches a copyright statement to encoded JPEG bytes. +/// +/// Only JPEG: it is the format that carries EXIF natively and the one that +/// nearly every copyright-bearing source uses. Returns the input untouched when +/// there is nothing to attach or the output is not a JPEG, so callers can apply +/// it unconditionally. +pub fn attach_copyright(encoded: Vec, copyright: &Copyright) -> Vec { + if copyright.is_empty() { + return encoded; + } + if encoded.len() < 2 || encoded[0] != MARKER_PREFIX || encoded[1] != MARKER_SOI { + debug!("Copyright retention skipped: output is not a JPEG"); + return encoded; + } + let Some(payload) = build_exif_payload(copyright) else { + return encoded; + }; + + let mut out = Vec::with_capacity(encoded.len() + payload.len() + 4); + out.extend_from_slice(&encoded[..2]); + out.push(MARKER_PREFIX); + out.push(MARKER_APP1); + out.extend_from_slice(&((payload.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&payload); + out.extend_from_slice(&encoded[2..]); + out +} + +/// Extracts the JPEG thumbnail embedded in a source's EXIF data. +/// +/// The offsets recorded in IFD1 are relative to the start of the TIFF block, so +/// the APP1 segment has to be located first; that is why this does not simply +/// hand the whole file to the EXIF reader. +pub fn embedded_thumbnail(image_bytes: &[u8]) -> Option> { + let tiff = jpeg_exif_block(image_bytes)?; + let exif = exif::Reader::new().read_raw(tiff.to_vec()).ok()?; + + let offset = exif + .get_field(Tag::JPEGInterchangeFormat, In::THUMBNAIL)? + .value + .get_uint(0)? as usize; + let length = exif + .get_field(Tag::JPEGInterchangeFormatLength, In::THUMBNAIL)? + .value + .get_uint(0)? as usize; + + let end = offset.checked_add(length)?; + if length == 0 || end > tiff.len() { + return None; + } + + let thumbnail = &tiff[offset..end]; + // Anything that is not a JPEG stream is not something to hand the decoder. + (thumbnail.starts_with(&[MARKER_PREFIX, MARKER_SOI])).then(|| thumbnail.to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn copyright_round_trips_through_a_rebuilt_app1_segment() { + // A JPEG stripped of metadata, then given its copyright back, has to be + // readable by an ordinary EXIF parser again — otherwise `keep_copyright` + // silently produces a file whose copyright no tool can find. + let mut jpeg = vec![0xFF, 0xD8]; + jpeg.extend_from_slice(&[0xFF, 0xDA, 0x00, 0x02]); + + let copyright = Copyright { + copyright: Some("(c) 2026 Example".to_string()), + artist: Some("A Photographer".to_string()), + }; + + let tagged = attach_copyright(jpeg.clone(), ©right); + assert_ne!(tagged, jpeg, "the segment should have been spliced in"); + assert_eq!(read_copyright(&tagged), copyright); + } + + #[test] + fn attaching_nothing_leaves_the_bytes_alone() { + let jpeg = vec![0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x02]; + assert_eq!(attach_copyright(jpeg.clone(), &Copyright::default()), jpeg); + + // A non-JPEG output cannot carry an APP1 segment, so it is returned + // untouched rather than corrupted with one. + let png = vec![0x89, b'P', b'N', b'G']; + let copyright = Copyright { + copyright: Some("(c) 2026".to_string()), + artist: None, + }; + assert_eq!(attach_copyright(png.clone(), ©right), png); + } + + #[test] + fn a_source_without_exif_has_no_copyright_and_no_thumbnail() { + let jpeg = vec![0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x02]; + assert!(read_copyright(&jpeg).is_empty()); + assert_eq!(embedded_thumbnail(&jpeg), None); + assert_eq!(embedded_thumbnail(b"not an image"), None); + } +} diff --git a/src/processing/mod.rs b/src/processing/mod.rs index b3aab32..7e69555 100644 --- a/src/processing/mod.rs +++ b/src/processing/mod.rs @@ -1,12 +1,20 @@ +//! Image processing: what happens between a decoded source and encoded bytes. + +pub mod animation; +pub mod colorspace; +pub mod metadata; pub mod options; +pub mod pipeline; pub mod presets; pub mod save; +pub mod scale_on_load; pub mod transform; pub mod utils; pub mod watermark; use crate::monitoring::{increment_processed_images, observe_image_processing_duration}; use crate::processing::options::ParsedOptions; +use crate::processing::pipeline::PipelineError; use crate::processing::watermark::CachedWatermark; use bytes::Bytes; use libvips::VipsImage; @@ -14,6 +22,8 @@ use std::time::Instant; use thiserror::Error; use tracing::debug; +pub use scale_on_load::{load_scale_factor, load_shrink_factor}; + /// Errors produced by the image processing pipeline. #[derive(Debug, Error)] #[non_exhaustive] @@ -26,121 +36,17 @@ pub enum ProcessingError { Save(#[from] save::SaveError), #[error("processed image would be {width}x{height}, over the {limit}px result dimension limit")] ResultTooLarge { width: i32, height: i32, limit: u32 }, + #[error("animation frame is {width}x{height}, over the {limit} pixel frame limit")] + FrameTooLarge { width: i32, height: i32, limit: u64 }, } -/// The JPEG loader can decode at 1/2, 1/4 or 1/8 scale, skipping the work -/// rather than doing it and throwing the result away. -const MAX_LOAD_SHRINK: u32 = 8; - -/// Below this, re-decoding at a reduced scale is not worth the divergence: at -/// 1.5 the pixel count already drops to 44%, and under it the saving thins out -/// fast. -const MIN_LOAD_SHRINK: f64 = 1.5; - -/// How much larger the source is than what the request needs, as a ratio. -/// -/// `None` means decode it whole: `raw` returns the source untouched, and a crop -/// addresses source pixels by coordinate, so shrinking underneath it would move -/// the region being cut. -fn load_shrink_ratio(parsed_options: &ParsedOptions, src_width: u32, src_height: u32) -> Option { - if parsed_options.raw { - return None; - } - // Trim removes an unknown number of pixels, so there is no way to tell how - // many will be left for the resize. Choosing a decode scale against that is - // guesswork, and guessing low leaves the resize short. imgproxy stands - // aside here too. - if parsed_options.trim.is_some() { - return None; - } - let resize = parsed_options.resize.as_ref()?; - if src_width == 0 || src_height == 0 { - return None; - } - - // A crop runs before the resize, so the pixels that have to survive are the - // crop region, not the whole source. Measuring against the source would - // shrink past what the crop still needs: an 8000x6000 source cropped to - // 2000x1500 and resized to 500 wide can only lose a factor of 4, not 16. - let (available_width, available_height) = match parsed_options.crop.as_ref() { - Some(crop) => ( - if crop.width == 0 { - src_width - } else { - crop.width.min(src_width) - }, - if crop.height == 0 { - src_height - } else { - crop.height.min(src_height) - }, - ), - None => (src_width, src_height), - }; - - // Anything that can grow the target after this point has to be folded in, - // or the shrink could drop the source below what the pipeline still needs. - let grow = - f64::from(parsed_options.dpr.unwrap_or(1.0).max(1.0)) * f64::from(parsed_options.zoom.unwrap_or(1.0).max(1.0)); - - // `force` fills a zero axis from the *source* dimension, so that axis needs - // the source at full size. Every other type derives a zero axis from the - // aspect ratio, which survives a shrink unchanged. - let forced = resize.resizing_type == "force"; - let target_width = if forced && resize.width == 0 { - f64::from(available_width) - } else { - (f64::from(resize.width) * grow).max(f64::from(parsed_options.min_width.unwrap_or(0))) - }; - let target_height = if forced && resize.height == 0 { - f64::from(available_height) - } else { - (f64::from(resize.height) * grow).max(f64::from(parsed_options.min_height.unwrap_or(0))) - }; - - // The *least* shrink any axis needs, so the decoded image is still at least - // as large as the target on both. Overshooting would hand the pipeline a - // source smaller than the request, which `enlarge:false` then refuses to - // scale back up. - let mut ratio = f64::INFINITY; - if target_width >= 1.0 { - ratio = ratio.min(f64::from(available_width) / target_width); - } - if target_height >= 1.0 { - ratio = ratio.min(f64::from(available_height) / target_height); - } - (ratio.is_finite() && ratio >= MIN_LOAD_SHRINK).then_some(ratio) -} - -/// Power-of-two shrink for the JPEG loader, or 1 to decode at full size. -pub fn load_shrink_factor(parsed_options: &ParsedOptions, src_width: u32, src_height: u32) -> u32 { - let Some(ratio) = load_shrink_ratio(parsed_options, src_width, src_height) else { - return 1; - }; - - let mut factor = 1; - while factor * 2 <= MAX_LOAD_SHRINK && f64::from(factor * 2) <= ratio { - factor *= 2; +impl From for ProcessingError { + fn from(error: PipelineError) -> Self { + match error { + PipelineError::Transform(error) => Self::Transform(error), + PipelineError::Watermark(error) => Self::Watermark(error), + } } - factor -} - -/// Continuous scale for the WebP loader, or `None` to decode at full size. -/// -/// WebP takes a scale rather than JPEG's power-of-two shrink, so it can decode -/// much closer to what is needed — a request needing a 3x reduction gets one, -/// where the JPEG path has to settle for 2x. -/// -/// The loader rounds decoded dimensions to nearest and can round down — 4000 x -/// 0.3333 is 1333.2 and decodes to 1333 — so an undershoot would be possible -/// with a scale that had been truncated on its way in. Deriving it exactly from -/// the target avoids that: the multiplication lands back on the target and the -/// rounding has nothing to shave. Checked over several million source/target -/// pairs, and guarded by a test that decodes real WebP data rather than -/// modelling the rounding. -pub fn load_scale_factor(parsed_options: &ParsedOptions, src_width: u32, src_height: u32) -> Option { - let scale = 1.0 / load_shrink_ratio(parsed_options, src_width, src_height)?; - (scale > 0.0 && scale < 1.0).then_some(scale) } /// Processes an image by applying the given `ParsedOptions`. @@ -160,7 +66,7 @@ pub fn load_scale_factor(parsed_options: &ParsedOptions, src_width: u32, src_hei /// /// A `Result` containing the processed image bytes on success, or a typed processing error. pub fn process_image( - mut img: VipsImage, + img: VipsImage, mut parsed_options: ParsedOptions, source_bytes: &Bytes, watermark: Option<&CachedWatermark>, @@ -168,224 +74,192 @@ pub fn process_image( let start = Instant::now(); debug!("Starting image processing with options: {:?}", parsed_options); - // Apply DPR scaling - if let Some(dpr) = parsed_options.dpr { - if dpr > 1.0 { - debug!("Applying DPR scaling: {}", dpr); - if let Some(ref mut resize) = parsed_options.resize { - debug!( - "Scaling resize dimensions from {}x{} to {}x{}", - resize.width, - resize.height, - (resize.width as f32 * dpr).round() as u32, - (resize.height as f32 * dpr).round() as u32 - ); - resize.width = (resize.width as f32 * dpr).round() as u32; - resize.height = (resize.height as f32 * dpr).round() as u32; - } - if let Some(ref mut padding) = parsed_options.padding { - debug!( - "Scaling padding from {:?} to {:?}", - padding, - ( - (padding.0 as f32 * dpr).round() as u32, - (padding.1 as f32 * dpr).round() as u32, - (padding.2 as f32 * dpr).round() as u32, - (padding.3 as f32 * dpr).round() as u32 - ) - ); - padding.0 = (padding.0 as f32 * dpr).round() as u32; - padding.1 = (padding.1 as f32 * dpr).round() as u32; - padding.2 = (padding.2 as f32 * dpr).round() as u32; - padding.3 = (padding.3 as f32 * dpr).round() as u32; - } - } - } + apply_dpr(&mut parsed_options); debug!("Loaded image: {}x{}", img.get_width(), img.get_height()); - // Apply EXIF autorotation if enabled - if parsed_options.auto_rotate { - debug!("Applying EXIF auto-rotation"); - img = transform::apply_exif_rotation(source_bytes.as_ref(), img)?; - } + let output_format = parsed_options.format.as_deref().unwrap_or("jpeg").to_string(); - // Trim before anything that depends on the image's extent: the borders it - // removes would otherwise skew the crop window and the resize target. - if let Some(ref trim) = parsed_options.trim { - debug!("Applying trim: {:?}", trim); - img = transform::apply_trim(img, trim)?; - } + // Colour management runs before the frames are split so a CMYK or + // wide-gamut source is converted once rather than once per frame. + let keep_high_bit_depth = + parsed_options.save.preserve_hdr.unwrap_or(false) && save::format_supports_high_bit_depth(&output_format); + let img = colorspace::to_processing(img, keep_high_bit_depth)?; - // Apply crop if specified - if let Some(crop) = parsed_options.crop { - debug!("Applying crop: {:?}", crop); - img = transform::crop_image(img, crop)?; - } + let orientation = parsed_options + .auto_rotate + .then(|| crate::utils::read_exif_orientation(source_bytes)) + .flatten(); - // Apply resize if specified - let mut resolved_resize_dims: Option<(u32, u32)> = None; - if let Some(ref resize) = parsed_options.resize { - let src_width = img.get_width() as u32; - let src_height = img.get_height() as u32; - let (target_w, target_h) = transform::resolve_resize_dimensions(resize, src_width, src_height)?; - debug!( - "Applying resize {:?} resolved to {}x{} from source {}x{}", - resize, target_w, target_h, src_width, src_height - ); - resolved_resize_dims = Some((target_w, target_h)); - - // The enlargement cap lives inside apply_resize, per resizing type. It - // used to be here, comparing the requested box against the source and - // skipping the whole resize when either side was larger — which threw - // away downscales that never enlarged anything. - img = transform::apply_resize( - img, - resize, - &parsed_options.gravity, - parsed_options.resizing_algorithm.as_deref(), - parsed_options.enlarge, - )?; - } + let frames = animation::split(&img)?; + enforce_frame_limit(&parsed_options, &frames, source_bytes)?; - // Apply min dimensions if specified - if parsed_options.min_width.is_some() || parsed_options.min_height.is_some() { - debug!( - "Applying min dimensions: min_width={:?}, min_height={:?}", - parsed_options.min_width, parsed_options.min_height - ); - img = transform::apply_min_dimensions( - img, - parsed_options.min_width, - parsed_options.min_height, - parsed_options.resizing_algorithm.as_deref(), - )?; - } + let processed = frames + .images + .into_iter() + .map(|mut frame| { + if let Some(orientation) = orientation { + frame = transform::apply_exif_orientation(frame, orientation)?; + } + Ok(pipeline::transform_frame(frame, &parsed_options, watermark)?) + }) + .collect::, ProcessingError>>()?; - // Apply zoom if specified - if let Some(zoom) = parsed_options.zoom { - debug!("Applying zoom: {}", zoom); - img = transform::apply_zoom(img, zoom, parsed_options.resizing_algorithm.as_deref())?; - } + let (mut img, page_height) = animation::join(processed)?; - // Apply extend if specified - if parsed_options.extend { - debug!("Applying extend option"); - if let Some((target_w, target_h)) = resolved_resize_dims { - if img.get_width() < target_w as i32 || img.get_height() < target_h as i32 { - let extend_w = target_w.max(img.get_width() as u32); - let extend_h = target_h.max(img.get_height() as u32); - img = transform::extend_image( - img, - extend_w, - extend_h, - &parsed_options.gravity, - &parsed_options.background, - )?; - } + img = colorspace::to_result(img, save::format_supports_color_profile(&output_format))?; + + // A format without an alpha channel needs the transparency resolved before + // it reaches the encoder, or the alpha is dropped against whatever happens + // to be underneath it. + if let Some(bg_color) = parsed_options.background { + if !save::format_supports_alpha(&output_format) { + debug!("Flattening onto {:?} for {} output", bg_color, output_format); + img = transform::apply_background_color(img, bg_color)?; } } - // Apply padding if specified - if let Some((top, right, bottom, left)) = parsed_options.padding { - debug!("Applying padding: {:?}", (top, right, bottom, left)); - img = transform::apply_padding(img, top, right, bottom, left, &parsed_options.background)?; - } + enforce_result_dimension(&parsed_options, &img)?; - // Apply rotation if specified - if let Some(rotation) = parsed_options.rotation { - debug!("Applying rotation: {}", rotation); - img = transform::apply_rotation(img, rotation)?; + let quality = parsed_options + .quality + .or_else(|| parsed_options.save.format_quality.get(&output_format).copied()) + .unwrap_or(85); + let mut output_vec = save::save_image_with_options( + img, + &output_format, + quality, + &parsed_options.save, + page_height.filter(|_| save::format_supports_animation(&output_format)), + )?; + + if parsed_options.save.retains_copyright() { + let copyright = metadata::read_copyright(source_bytes); + if !copyright.is_empty() { + debug!("Re-attaching copyright after metadata strip"); + output_vec = metadata::attach_copyright(output_vec, ©right); + } } - // Apply flip if specified - if let Some(flip) = parsed_options.flip { - debug!("Applying flip: {:?}", flip); - img = transform::apply_flip(img, flip)?; - } + let output_bytes = Bytes::from(output_vec); - // Apply color adjustments if specified - if let Some(adjust) = parsed_options.adjust { - debug!("Applying color adjustments: {:?}", adjust); - img = transform::apply_adjust(img, adjust)?; - } + debug!("Image processing complete"); - // Apply blur if specified - if let Some(sigma) = parsed_options.blur { - debug!("Applying blur with sigma: {}", sigma); - img = transform::apply_blur(img, sigma)?; - } + let duration = start.elapsed().as_secs_f64(); + observe_image_processing_duration(&output_format, duration); + increment_processed_images(&output_format); - // Apply sharpen if specified - if let Some(sigma) = parsed_options.sharpen { - debug!("Applying sharpen with sigma: {}", sigma); - img = transform::apply_sharpen(img, sigma)?; - } + Ok(output_bytes) +} - // Apply pixelate if specified - if let Some(amount) = parsed_options.pixelate { - debug!("Applying pixelate with amount: {}", amount); - img = transform::apply_pixelate(img, amount, parsed_options.resizing_algorithm.as_deref())?; +/// Scales everything the device pixel ratio applies to. +/// +/// DPR multiplies the *requested* geometry rather than the result, so it has to +/// land on the resize target and the padding before either is used. Applying it +/// afterwards would scale the padding along with the image, which is not what a +/// 2x display asks for. +fn apply_dpr(parsed_options: &mut ParsedOptions) { + let dpr = parsed_options.dpr_factor(); + if dpr <= 1.0 { + return; } - // Apply watermark if specified - if let Some(ref watermark_opts) = parsed_options.watermark { - if let Some(watermark) = watermark { - debug!("Applying watermark with options: {:?}", watermark_opts); - img = watermark::apply_watermark( - img, - watermark, - watermark_opts, - parsed_options.resizing_algorithm.as_deref(), - )?; - } + debug!("Applying DPR scaling: {}", dpr); + if let Some(resize) = parsed_options.resize.as_mut() { + resize.width = (resize.width as f32 * dpr).round() as u32; + resize.height = (resize.height as f32 * dpr).round() as u32; + } + if let Some(padding) = parsed_options.padding.as_mut() { + padding.0 = (padding.0 as f32 * dpr).round() as u32; + padding.1 = (padding.1 as f32 * dpr).round() as u32; + padding.2 = (padding.2 as f32 * dpr).round() as u32; + padding.3 = (padding.3 as f32 * dpr).round() as u32; } +} - // Apply background color for JPEG if needed - let output_format = parsed_options.format.as_deref().unwrap_or("jpeg"); - if let Some(bg_color) = parsed_options.background { - if output_format == "jpeg" { - debug!("Applying background color for JPEG output: {:?}", bg_color); - img = transform::apply_background_color(img, bg_color)?; - } +/// Rejects an animation whose individual frames are too large. +/// +/// The source-resolution limit measures the whole stack, which for an animation +/// is the frame size multiplied by the frame count; this bounds what a single +/// frame may cost, which is what imgproxy's `max_animation_frame_resolution` +/// does. +fn enforce_frame_limit( + parsed_options: &ParsedOptions, + frames: &animation::Frames, + source_bytes: &Bytes, +) -> Result<(), ProcessingError> { + let Some(limit) = parsed_options.max_animation_frame_resolution else { + return Ok(()); + }; + let Some(frame) = frames.images.first() else { + return Ok(()); + }; + + // How many frames are in hand is not the question. `disable_animation`, a + // still output format and an explicit `pages:1` all collapse an animated + // source to a single frame, and that frame is still an animation frame — + // treating it as a still image would let any of the three ask for the first + // frame of an enormous animation and be handed it. Only the source can say + // whether this is an animation, so when one frame is in hand it is the + // source that gets asked. + if frames.images.len() <= 1 && !source_is_animated(source_bytes) { + return Ok(()); } - // Enforce the result-dimension ceiling before encoding. libvips has built a - // pipeline but not materialised it yet, so the dimensions are already known - // while the pixels are not — rejecting here avoids the allocation entirely - // rather than reporting it afterwards. - if let Some(limit) = parsed_options.max_result_dimension { - let (width, height) = (img.get_width(), img.get_height()); - if width.max(height) as u32 > limit.get() { - debug!( - "Result {}x{} exceeds max_result_dimension {}", - width, - height, - limit.get() - ); - return Err(ProcessingError::ResultTooLarge { - width, - height, - limit: limit.get(), - }); - } + let (width, height) = (frame.get_width(), frame.get_height()); + let pixels = u64::try_from(width) + .unwrap_or(0) + .saturating_mul(u64::try_from(height).unwrap_or(0)); + if pixels > limit.pixels() { + return Err(ProcessingError::FrameTooLarge { + width, + height, + limit: limit.pixels(), + }); } - // Save image to bytes - let quality = parsed_options - .quality - .or_else(|| parsed_options.save.format_quality.get(output_format).copied()) - .unwrap_or(85); - let output_vec = save::save_image_with_options(img, output_format, quality, &parsed_options.save)?; - let output_bytes = Bytes::from(output_vec); + Ok(()) +} - debug!("Image processing complete"); +/// Whether the source itself carries more than one page. +/// +/// Reopening reads the header and decodes nothing, and this is only reached +/// once the operator has configured the per-frame limit, so the cost lands on +/// the deployments that asked for the check. A source that will not reopen is +/// not treated as an animation: it is about to fail for its own reasons, and +/// guessing here would turn a decode error into a limit error. +fn source_is_animated(source_bytes: &Bytes) -> bool { + VipsImage::new_from_buffer(source_bytes, "n=-1") + .map(|img| img.get_n_pages() > 1) + .unwrap_or(false) +} - let duration = start.elapsed().as_secs_f64(); - observe_image_processing_duration(output_format, duration); - increment_processed_images(output_format); +/// Enforces the result-dimension ceiling before encoding. +/// +/// libvips has built a pipeline but not materialised it yet, so the dimensions +/// are already known while the pixels are not — rejecting here avoids the +/// allocation entirely rather than reporting it afterwards. +fn enforce_result_dimension(parsed_options: &ParsedOptions, img: &VipsImage) -> Result<(), ProcessingError> { + let Some(limit) = parsed_options.max_result_dimension else { + return Ok(()); + }; - Ok(output_bytes) + let (width, height) = (img.get_width(), img.get_height()); + if width.max(height) as u32 > limit.get() { + debug!( + "Result {}x{} exceeds max_result_dimension {}", + width, + height, + limit.get() + ); + return Err(ProcessingError::ResultTooLarge { + width, + height, + limit: limit.get(), + }); + } + + Ok(()) } #[cfg(test)] diff --git a/src/processing/options.rs b/src/processing/options.rs deleted file mode 100644 index 9936874..0000000 --- a/src/processing/options.rs +++ /dev/null @@ -1,1266 +0,0 @@ -//! Image processing module for imgforge. -//! This module contains functions and structs for parsing image processing options -//! and applying various transformations to images. - -/// Represents a single image processing option from the URL path. -#[derive(Debug, Clone)] -pub struct ProcessingOption { - /// The name of the processing option (e.g., "resize", "quality"). - pub name: String, - /// Arguments for the processing option. - pub args: Vec, -} -use crate::limits::{MaxResultDimension, MaxSourceFileSize, MaxSourceResolution}; -use base64::engine::general_purpose; -use base64::Engine as _; -use std::collections::HashMap; -use std::str::FromStr; -use thiserror::Error; -use tracing::debug; - -/// Errors produced while parsing image processing options. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum OptionParseError { - #[error("invalid {option} value {value:?}")] - Integer { - option: String, - value: String, - #[source] - source: std::num::ParseIntError, - }, - #[error("invalid {option} value {value:?}")] - Float { - option: String, - value: String, - #[source] - source: std::num::ParseFloatError, - }, - #[error("invalid Base64 for {option}")] - Base64 { - option: String, - #[source] - source: base64::DecodeError, - }, - #[error("invalid UTF-8 for {option}")] - Utf8 { - option: String, - #[source] - source: std::string::FromUtf8Error, - }, - #[error("invalid {option}: {source}")] - SecurityLimit { - option: String, - #[source] - source: crate::limits::SecurityLimitError, - }, - #[error("invalid background color")] - Color(#[source] super::utils::ColorParseError), - #[error("{0}")] - InvalidValue(String), -} - -impl OptionParseError { - fn invalid(message: impl Into) -> Self { - Self::InvalidValue(message.into()) - } -} - -fn parse_integer(value: &str, option: &str) -> Result -where - T: FromStr, -{ - value.parse().map_err(|source| OptionParseError::Integer { - option: option.to_string(), - value: value.to_string(), - source, - }) -} - -fn parse_float(value: &str, option: &str) -> Result { - value.parse().map_err(|source| OptionParseError::Float { - option: option.to_string(), - value: value.to_string(), - source, - }) -} - -fn decode_base64(value: &str, option: &str) -> Result, OptionParseError> { - general_purpose::URL_SAFE_NO_PAD - .decode(value) - .map_err(|source| OptionParseError::Base64 { - option: option.to_string(), - source, - }) -} - -fn decode_utf8(value: Vec, option: &str) -> Result { - String::from_utf8(value).map_err(|source| OptionParseError::Utf8 { - option: option.to_string(), - source, - }) -} - -/// Option name for resizing. -const RESIZE: &str = "resize"; -/// Shorthand for resize. -const RESIZE_SHORT: &str = "rs"; -/// Option name for resizing type. -const RESIZING_TYPE: &str = "resizing_type"; -/// Shorthand for resizing type. -const RESIZING_TYPE_SHORT: &str = "rt"; -/// Option name for size. -const SIZE: &str = "size"; -/// Shorthand for size. -const SIZE_SHORT: &str = "s"; -/// Option name for width. -const WIDTH: &str = "width"; -/// Shorthand for width. -const WIDTH_SHORT: &str = "w"; -/// Option name for height. -const HEIGHT: &str = "height"; -/// Shorthand for height. -const HEIGHT_SHORT: &str = "h"; -/// Option name for gravity. -const GRAVITY: &str = "gravity"; -/// Shorthand for gravity. -const GRAVITY_SHORT: &str = "g"; -/// Option name for quality. -const QUALITY: &str = "quality"; -/// Shorthand for quality. -const QUALITY_SHORT: &str = "q"; -/// Option name for format-specific quality. -const FORMAT_QUALITY: &str = "format_quality"; -/// Shorthand for format_quality. -const FORMAT_QUALITY_SHORT: &str = "fq"; -/// Option name for auto_rotate. -const AUTO_ROTATE: &str = "auto_rotate"; -/// Shorthand for auto_rotate. -const AUTO_ROTATE_SHORT: &str = "ar"; -/// Option name for background. -const BACKGROUND: &str = "background"; -/// Shorthand for background. -const BACKGROUND_SHORT: &str = "bg"; -/// Option name for enlarge. -const ENLARGE: &str = "enlarge"; -/// Shorthand for enlarge. -const ENLARGE_SHORT: &str = "el"; -/// Option name for extend. -const EXTEND: &str = "extend"; -/// Shorthand for extend. -const EXTEND_SHORT: &str = "ex"; -/// Option name for padding. -const PADDING: &str = "padding"; -/// Shorthand for padding. -const PADDING_SHORT: &str = "pd"; -/// Option name for rotation. -const ROTATE: &str = "rotate"; -/// Shorthand for rotation. -const ROTATE_SHORT: &str = "rot"; -/// Option name for flip. -const FLIP: &str = "flip"; -/// Shorthand for flip. -const FLIP_SHORT: &str = "fl"; -/// Option name for raw. -const RAW: &str = "raw"; -/// Option name for blur. -const BLUR: &str = "blur"; -/// Shorthand for blur. -const BLUR_SHORT: &str = "bl"; -/// Option name for crop. -const CROP: &str = "crop"; -/// Shorthand for crop. -const CROP_SHORT: &str = "c"; -/// Option name for format. -const FORMAT: &str = "format"; -/// Shorthand for format. -const FORMAT_SHORT: &str = "f"; -/// Alternate shorthand for format. -const FORMAT_EXT: &str = "ext"; -/// Option name for max_src_resolution. -const MAX_SRC_RESOLUTION: &str = "max_src_resolution"; -/// Shorthand for max_src_resolution. -const MAX_SRC_RESOLUTION_SHORT: &str = "msr"; -/// Option name for trim. -const TRIM: &str = "trim"; -/// Shorthand for trim. -const TRIM_SHORT: &str = "t"; -/// Option name for max_result_dimension. -const MAX_RESULT_DIMENSION: &str = "max_result_dimension"; -/// Shorthand for max_result_dimension. -const MAX_RESULT_DIMENSION_SHORT: &str = "mrd"; -/// Option name for max_src_file_size. -const MAX_SRC_FILE_SIZE: &str = "max_src_file_size"; -/// Shorthand for max_src_file_size. -const MAX_SRC_FILE_SIZE_SHORT: &str = "msfs"; -/// Option name for cache buster. -const CACHEBUSTER: &str = "cachebuster"; -/// Shorthand for cachebuster. -const CACHEBUSTER_SHORT: &str = "cb"; -/// Option name for dpr. -const DPR: &str = "dpr"; -/// Option name for min-width. -const MIN_WIDTH: &str = "min-width"; -/// Shorthand for min_width. -const MIN_WIDTH_SHORT: &str = "mw"; -/// Option name for min-height. -const MIN_HEIGHT: &str = "min-height"; -/// Shorthand for min_height. -const MIN_HEIGHT_SHORT: &str = "mh"; -/// Option name for zoom. -const ZOOM: &str = "zoom"; -/// Shorthand for zoom. -const ZOOM_SHORT: &str = "z"; -/// Option name for sharpen. -const SHARPEN: &str = "sharpen"; -/// Shorthand for sharpen. -const SHARPEN_SHORT: &str = "sh"; -/// Option name for pixelate. -const PIXELATE: &str = "pixelate"; -/// Shorthand for pixelate. -const PIXELATE_SHORT: &str = "pix"; -/// Option name for watermark. -const WATERMARK: &str = "watermark"; -/// Shorthand for watermark. -const WATERMARK_SHORT: &str = "wm"; -/// Option name for watermark_url. -const WATERMARK_URL: &str = "watermark_url"; -/// Shorthand for watermark_url. -const WATERMARK_URL_SHORT: &str = "wmu"; -/// Option name for resizing_algorithm. -const RESIZING_ALGORITHM: &str = "resizing_algorithm"; -/// Shorthand for resizing_algorithm. -const RESIZING_ALGORITHM_SHORT: &str = "ra"; -/// Option name for background_alpha. -const BACKGROUND_ALPHA: &str = "background_alpha"; -/// Shorthand for background_alpha. -const BACKGROUND_ALPHA_SHORT: &str = "bga"; -/// Option name for adjust. -const ADJUST: &str = "adjust"; -/// Shorthand for adjust. -const ADJUST_SHORT: &str = "a"; -/// Option name for brightness. -const BRIGHTNESS: &str = "brightness"; -/// Shorthand for brightness. -const BRIGHTNESS_SHORT: &str = "br"; -/// Option name for contrast. -const CONTRAST: &str = "contrast"; -/// Shorthand for contrast. -const CONTRAST_SHORT: &str = "co"; -/// Option name for saturation. -const SATURATION: &str = "saturation"; -/// Shorthand for saturation. -const SATURATION_SHORT: &str = "sa"; -/// Option name for max_bytes. -const MAX_BYTES: &str = "max_bytes"; -/// Shorthand for max_bytes. -const MAX_BYTES_SHORT: &str = "mb"; -/// Option name for strip_metadata. -const STRIP_METADATA: &str = "strip_metadata"; -/// Shorthand for strip_metadata. -const STRIP_METADATA_SHORT: &str = "sm"; -/// Option name for strip_color_profile. -const STRIP_COLOR_PROFILE: &str = "strip_color_profile"; -/// Shorthand for strip_color_profile. -const STRIP_COLOR_PROFILE_SHORT: &str = "scp"; -/// Option name for JPEG options. -const JPEG_OPTIONS: &str = "jpeg_options"; -/// Shorthand for JPEG options. -const JPEG_OPTIONS_SHORT: &str = "jpgo"; -/// Option name for PNG options. -const PNG_OPTIONS: &str = "png_options"; -/// Shorthand for PNG options. -const PNG_OPTIONS_SHORT: &str = "pngo"; -/// Option name for WebP options. -const WEBP_OPTIONS: &str = "webp_options"; -/// Shorthand for WebP options. -const WEBP_OPTIONS_SHORT: &str = "webpo"; -/// Option name for AVIF options. -const AVIF_OPTIONS: &str = "avif_options"; -/// Shorthand for AVIF options. -const AVIF_OPTIONS_SHORT: &str = "avifo"; -/// Option name for page. -const PAGE: &str = "page"; -/// Shorthand for page. -const PAGE_SHORT: &str = "pg"; -/// Option name for pages. -const PAGES: &str = "pages"; -/// Shorthand for pages. -const PAGES_SHORT: &str = "pgs"; -/// Option name for disable_animation. -const DISABLE_ANIMATION: &str = "disable_animation"; -/// Shorthand for disable_animation. -const DISABLE_ANIMATION_SHORT: &str = "da"; -/// Option name for skip_processing. -const SKIP_PROCESSING: &str = "skip_processing"; -/// Shorthand for skip_processing. -const SKIP_PROCESSING_SHORT: &str = "skp"; -/// Option name for expires. -const EXPIRES: &str = "expires"; -/// Shorthand for expires. -const EXPIRES_SHORT: &str = "exp"; -/// Option name for filename. -const FILENAME: &str = "filename"; -/// Shorthand for filename. -const FILENAME_SHORT: &str = "fn"; -/// Option name for return_attachment. -const RETURN_ATTACHMENT: &str = "return_attachment"; -/// Shorthand for return_attachment. -const RETURN_ATTACHMENT_SHORT: &str = "att"; - -const VALID_ROTATIONS: [u16; 4] = [0, 90, 180, 270]; -const VALID_RESIZING_TYPES: [&str; 4] = ["fill", "fit", "force", "auto"]; - -fn is_valid_rotation(rotation: u16) -> bool { - VALID_ROTATIONS.contains(&rotation) -} - -fn is_valid_resizing_type(resizing_type: &str) -> bool { - VALID_RESIZING_TYPES.contains(&resizing_type) -} - -fn parse_positive_f32(value: &str, option_name: &str) -> Result { - let parsed = parse_float(value, option_name)?; - - if !parsed.is_finite() || parsed <= 0.0 { - return Err(OptionParseError::invalid(format!( - "{} must be a finite positive number", - option_name - ))); - } - - Ok(parsed) -} - -fn parse_unit_f32(value: &str, option_name: &str) -> Result { - let parsed = parse_float(value, option_name)?; - - if !parsed.is_finite() || !(0.0..=1.0).contains(&parsed) { - return Err(OptionParseError::invalid(format!( - "{} must be a finite number between 0 and 1", - option_name - ))); - } - - Ok(parsed) -} - -fn parse_quality(value: &str, option_name: &str) -> Result { - Ok(parse_integer::(value, option_name)?.clamp(1, 100)) -} - -fn parse_optional_bool(args: &[String], index: usize) -> Option { - args.get(index) - .filter(|arg| !arg.is_empty()) - .map(|arg| super::utils::parse_boolean(arg)) -} - -/// Represents the parameters for a resize operation. -#[derive(Debug, Default)] -pub struct Resize { - /// The type of resizing to perform (e.g., "fill", "fit", "force"). - pub resizing_type: String, - /// The target width for the resize operation. - pub width: u32, - /// The target height for the resize operation. - pub height: u32, -} - -/// Controls how an image is aligned when cropping or extending. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Gravity { - Center, - North, - South, - East, - West, - NorthEast, - NorthWest, - SouthEast, - SouthWest, -} - -impl Gravity { - pub fn parse(value: &str) -> Option { - match value { - "ce" => Some(Self::Center), - "no" => Some(Self::North), - "so" => Some(Self::South), - "ea" => Some(Self::East), - "we" => Some(Self::West), - "noea" => Some(Self::NorthEast), - "nowe" => Some(Self::NorthWest), - "soea" => Some(Self::SouthEast), - "sowe" => Some(Self::SouthWest), - _ => None, - } - } -} - -/// Represents the parameters for a flip operation. -#[derive(Debug, Clone, Copy, Default)] -pub struct Flip { - pub horizontal: bool, - pub vertical: bool, -} - -/// Represents the parameters for color adjustment. -#[derive(Debug, Clone, Copy)] -pub struct Adjust { - pub brightness: i16, - pub contrast: f32, - pub saturation: f32, -} - -impl Default for Adjust { - fn default() -> Self { - Self { - brightness: 0, - contrast: 1.0, - saturation: 1.0, - } - } -} - -/// Encoder-specific output controls. -#[derive(Debug, Clone, Default)] -pub struct SaveOptions { - pub format_quality: HashMap, - pub max_bytes: Option, - pub strip_metadata: Option, - pub strip_color_profile: Option, - pub jpeg: JpegOptions, - pub png: PngOptions, - pub webp: WebpOptions, - pub avif: AvifOptions, -} - -/// JPEG encoder controls. -#[derive(Debug, Clone, Default)] -pub struct JpegOptions { - pub progressive: Option, - pub no_subsample: Option, - pub trellis_quant: Option, - pub overshoot_deringing: Option, - pub optimize_scans: Option, - pub quant_table: Option, -} - -/// PNG encoder controls. -#[derive(Debug, Clone, Default)] -pub struct PngOptions { - pub interlaced: Option, - pub quantize: Option, - pub quantization_colors: Option, -} - -/// WebP encoder controls. -#[derive(Debug, Clone, Default)] -pub struct WebpOptions { - pub lossless: Option, - pub smart_subsample: Option, - pub preset: Option, -} - -/// Border trimming controls. -#[derive(Debug, Clone, Copy)] -pub struct Trim { - /// How far a pixel may differ from the background and still be trimmed. - pub threshold: f64, - /// Colour to treat as background. Detected from the top-left pixel when absent. - pub color: Option<[u8; 4]>, - /// Cut equal amounts from the left and right. - pub equal_hor: bool, - /// Cut equal amounts from the top and bottom. - pub equal_ver: bool, -} - -/// AVIF/HEIF encoder controls. -#[derive(Debug, Clone, Default)] -pub struct AvifOptions { - pub no_subsample: Option, -} - -/// Represents the parameters for a crop operation. -#[derive(Debug, Default)] -pub struct Crop { - /// The x-coordinate of the top-left corner of the crop area. - pub x: u32, - /// The y-coordinate of the top-left corner of the crop area. - pub y: u32, - /// The width of the crop area. - pub width: u32, - /// The height of the crop area. - pub height: u32, - /// Optional crop gravity. - pub gravity: Option, -} - -/// Represents the parameters for a watermark operation. -#[derive(Debug, Clone, Default)] -pub struct Watermark { - /// The opacity of the watermark. - pub opacity: f32, - /// The position of the watermark. - pub position: String, -} - -/// Holds all parsed image processing options. -#[derive(Debug)] -pub struct ParsedOptions { - /// Optional resize operation parameters. - pub resize: Option, - /// Optional blur sigma value. - pub blur: Option, - /// Optional crop operation parameters. - pub crop: Option, - /// Optional output image format. - pub format: Option, - /// Optional output image quality (1-100). - pub quality: Option, - /// Optional background color for transparent areas or extending. - pub background: Option<[u8; 4]>, // RGBA array - /// Optional target width (used with `resize` if no explicit resize type). - pub width: Option, - /// Optional target height (used with `resize` if no explicit resize type). - pub height: Option, - /// Optional gravity for cropping or extending. - pub gravity: Option, - /// Whether to allow enlarging the image beyond its original dimensions. - pub enlarge: bool, - /// Whether to extend the image with a background if target dimensions are larger. - pub extend: bool, - /// Optional padding values (top, right, bottom, left). - pub padding: Option<(u32, u32, u32, u32)>, - /// Optional image rotation (rotation angle). - pub rotation: Option, - /// Optional flip operation. - pub flip: Option, - /// Whether to automatically rotate the image based on EXIF data. - pub auto_rotate: bool, - /// Whether to bypass processing limits (e.g., worker limits). - pub raw: bool, - /// Maximum allowed source image resolution in megapixels. - pub max_src_resolution: Option, - /// Ceiling for either dimension of the processed image. - pub max_result_dimension: Option, - /// Border trimming, applied before crop and resize. - pub trim: Option, - /// Maximum allowed source image file size in bytes. - pub max_src_file_size: Option, - /// Value to bypass cache (e.g., timestamp). - pub cache_buster: Option, - /// Optional unix timestamp after which the request expires. - pub expires: Option, - /// Optional response filename for Content-Disposition. - pub filename: Option, - /// Whether to return Content-Disposition as attachment. - pub return_attachment: bool, - /// Device pixel ratio factor to scale up dimensions. - pub dpr: Option, - /// Minimum width for the image. - pub min_width: Option, - /// Minimum height for the image. - pub min_height: Option, - /// Zoom factor for the image. - pub zoom: Option, - /// Sharpen factor for the image. - pub sharpen: Option, - /// Pixelate factor for the image. - pub pixelate: Option, - pub watermark: Option, - /// Optional URL for a watermark image. - pub watermark_url: Option, - /// Resizing algorithm to use (nearest, linear, cubic, lanczos2, lanczos3). - pub resizing_algorithm: Option, - /// Optional alpha value applied to background. - pub background_alpha: Option, - /// Optional color adjustments. - pub adjust: Option, - /// Encoder-specific output options. - pub save: SaveOptions, - /// Optional page number for multi-page sources. - pub page: Option, - /// Optional page count for multi-page sources. - pub pages: Option, - /// Whether to disable animation handling. - pub disable_animation: bool, - /// Source formats that may bypass processing when output format matches. - pub skip_processing: Vec, -} - -impl Default for ParsedOptions { - fn default() -> Self { - Self { - resize: None, - blur: None, - crop: None, - format: None, - quality: None, - background: None, - width: None, - height: None, - gravity: None, - enlarge: false, - extend: false, - padding: None, - rotation: None, - flip: None, - auto_rotate: true, - raw: false, - max_src_resolution: None, - max_result_dimension: None, - trim: None, - max_src_file_size: None, - cache_buster: None, - expires: None, - filename: None, - return_attachment: false, - dpr: Some(1.0), - min_width: None, - min_height: None, - zoom: None, - sharpen: None, - pixelate: None, - watermark: None, - watermark_url: None, - resizing_algorithm: Some("lanczos3".to_string()), - background_alpha: None, - adjust: None, - save: SaveOptions::default(), - page: None, - pages: None, - disable_animation: false, - skip_processing: Vec::new(), - } - } -} - -/// Parses a vector of `ProcessingOption` into a `ParsedOptions` struct. -/// -/// This function iterates through the raw processing options, validates their arguments, -/// and converts them into a structured `ParsedOptions` object. -/// -/// # Arguments -/// -/// * `options` - A `Vec` containing the raw options from the URL. -/// -/// # Returns -/// -/// A `Result` containing the `ParsedOptions` on success, or a typed parsing error. -pub fn parse_all_options(options: Vec) -> Result { - let mut parsed_options = ParsedOptions::default(); - - for option in options { - debug!("Parsing option: {} with args: {:?}", option.name, option.args); - match option.name.as_str() { - RESIZE | RESIZE_SHORT => { - let mut store_resize = parsed_options.resize.is_some(); - let mut resize = parsed_options.resize.take().unwrap_or_default(); - - if let Some(arg) = option.args.first() { - if !arg.is_empty() { - resize.resizing_type = arg.clone(); - store_resize = true; - } - } - if let Some(arg) = option.args.get(1) { - if !arg.is_empty() { - resize.width = parse_integer(arg, "resize width")?; - store_resize = true; - } - } - if let Some(arg) = option.args.get(2) { - if !arg.is_empty() { - resize.height = parse_integer(arg, "resize height")?; - store_resize = true; - } - } - if let Some(arg) = option.args.get(3) { - if !arg.is_empty() { - parsed_options.enlarge = super::utils::parse_boolean(arg); - } - } - if let Some(arg) = option.args.get(4) { - if !arg.is_empty() { - parsed_options.extend = super::utils::parse_boolean(arg); - } - } - - if store_resize { - parsed_options.resize = Some(resize); - } - } - RESIZING_TYPE | RESIZING_TYPE_SHORT => { - let resizing_type = option - .args - .first() - .filter(|value| !value.is_empty()) - .ok_or_else(|| OptionParseError::invalid("resizing_type option requires one argument"))?; - if !is_valid_resizing_type(resizing_type) { - return Err(OptionParseError::invalid( - "resizing_type must be one of: fill, fit, force, auto", - )); - } - parsed_options - .resize - .get_or_insert_with(Resize::default) - .resizing_type - .clone_from(resizing_type); - } - SIZE | SIZE_SHORT => { - let mut store_resize = parsed_options.resize.is_some(); - let mut resize = parsed_options.resize.take().unwrap_or_default(); - let mut width_height_set = false; - - if let Some(arg) = option.args.first() { - if !arg.is_empty() { - resize.width = parse_integer(arg, "size width")?; - store_resize = true; - width_height_set = true; - } - } - if let Some(arg) = option.args.get(1) { - if !arg.is_empty() { - resize.height = parse_integer(arg, "size height")?; - store_resize = true; - width_height_set = true; - } - } - - if let Some(arg) = option.args.get(2) { - if !arg.is_empty() { - parsed_options.enlarge = super::utils::parse_boolean(arg); - } - } - if let Some(arg) = option.args.get(3) { - if !arg.is_empty() { - parsed_options.extend = super::utils::parse_boolean(arg); - } - } - - if store_resize && (width_height_set || resize.resizing_type.is_empty()) { - resize.resizing_type = "fit".to_string(); - } - - if store_resize { - parsed_options.resize = Some(resize); - } - } - WIDTH | WIDTH_SHORT => { - let width_arg = option.args.first().map(|s| s.as_str()).unwrap_or("0"); - let width = if width_arg.is_empty() { - 0 - } else { - parse_integer(width_arg, "width")? - }; - parsed_options.width = Some(width); - } - HEIGHT | HEIGHT_SHORT => { - let height_arg = option.args.first().map(|s| s.as_str()).unwrap_or("0"); - let height = if height_arg.is_empty() { - 0 - } else { - parse_integer(height_arg, "height")? - }; - parsed_options.height = Some(height); - } - GRAVITY | GRAVITY_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("gravity option requires one argument")); - } - let gravity = option.args[0].as_str(); - let gravity = Gravity::parse(gravity).ok_or_else(|| { - OptionParseError::invalid("gravity must be one of: ce, no, so, ea, we, noea, nowe, soea, sowe") - })?; - parsed_options.gravity = Some(gravity); - } - ENLARGE | ENLARGE_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("enlarge option requires one argument")); - } - parsed_options.enlarge = super::utils::parse_boolean(&option.args[0]); - } - EXTEND | EXTEND_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("extend option requires one argument")); - } - parsed_options.extend = super::utils::parse_boolean(&option.args[0]); - if let Some(gravity) = option.args.get(1).filter(|arg| !arg.is_empty()) { - parsed_options.gravity = Some(Gravity::parse(gravity).ok_or_else(|| { - OptionParseError::invalid( - "extend gravity must be one of: ce, no, so, ea, we, noea, nowe, soea, sowe", - ) - })?); - } - } - PADDING | PADDING_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid( - "padding option requires at least one argument", - )); - } - let values: Vec = option - .args - .iter() - .map(|value| parse_integer(value, "padding")) - .collect::, _>>()?; - parsed_options.padding = Some(match values.len() { - 1 => (values[0], values[0], values[0], values[0]), - 2 => (values[0], values[1], values[0], values[1]), - 4 => (values[0], values[1], values[2], values[3]), - _ => { - return Err(OptionParseError::invalid("padding must have 1, 2, or 4 arguments")); - } - }); - } - ROTATE | ROTATE_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("rotation option requires one argument")); - } - let rotation = parse_integer(&option.args[0], "rotation")?; - if !is_valid_rotation(rotation) { - return Err(OptionParseError::invalid("rotation must be one of: 0, 90, 180, 270")); - } - parsed_options.rotation = Some(rotation); - } - FLIP | FLIP_SHORT => { - parsed_options.flip = Some(Flip { - horizontal: parse_optional_bool(&option.args, 0).unwrap_or(false), - vertical: parse_optional_bool(&option.args, 1).unwrap_or(false), - }); - } - AUTO_ROTATE | AUTO_ROTATE_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("auto_rotate option requires one argument")); - } - parsed_options.auto_rotate = super::utils::parse_boolean(&option.args[0]); - } - RAW => { - parsed_options.raw = option - .args - .first() - .filter(|arg| !arg.is_empty()) - .map(|arg| super::utils::parse_boolean(arg)) - .unwrap_or(true); - } - BLUR | BLUR_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("blur option requires one argument: sigma")); - } - parsed_options.blur = Some(parse_positive_f32(&option.args[0], "blur")?); - } - CROP | CROP_SHORT => { - if option.args.len() < 2 { - return Err(OptionParseError::invalid( - "crop option requires at least two arguments: width, height", - )); - } - let gravity = option - .args - .get(2) - .filter(|arg| !arg.is_empty()) - .map(|arg| { - Gravity::parse(arg).ok_or_else(|| { - OptionParseError::invalid( - "crop gravity must be one of: ce, no, so, ea, we, noea, nowe, soea, sowe", - ) - }) - }) - .transpose()?; - parsed_options.crop = Some(Crop { - x: 0, - y: 0, - width: parse_integer(&option.args[0], "crop width")?, - height: parse_integer(&option.args[1], "crop height")?, - gravity, - }); - } - FORMAT | FORMAT_SHORT | FORMAT_EXT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("format option requires one argument")); - } - parsed_options.format = Some(option.args[0].clone()); - } - QUALITY | QUALITY_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("quality option requires one argument")); - } - parsed_options.quality = Some(parse_quality(&option.args[0], "quality")?); - } - FORMAT_QUALITY | FORMAT_QUALITY_SHORT => { - if option.args.len() < 2 || option.args.len() % 2 != 0 { - return Err(OptionParseError::invalid( - "format_quality option requires format/quality pairs", - )); - } - - for pair in option.args.chunks_exact(2) { - parsed_options - .save - .format_quality - .insert(pair[0].to_lowercase(), parse_quality(&pair[1], "format_quality")?); - } - } - BACKGROUND | BACKGROUND_SHORT => { - if option.args.is_empty() { - parsed_options.background = None; - continue; - } - let mut background = if option.args.len() >= 3 { - [ - parse_integer(&option.args[0], "background red channel")?, - parse_integer(&option.args[1], "background green channel")?, - parse_integer(&option.args[2], "background blue channel")?, - 255, - ] - } else { - super::utils::parse_hex_color(&option.args[0]).map_err(OptionParseError::Color)? - }; - if let Some(alpha) = parsed_options.background_alpha { - background[3] = (alpha * 255.0).round() as u8; - } - parsed_options.background = Some(background); - } - BACKGROUND_ALPHA | BACKGROUND_ALPHA_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid( - "background_alpha option requires one argument", - )); - } - let alpha = parse_unit_f32(&option.args[0], "background_alpha")?; - parsed_options.background_alpha = Some(alpha); - if let Some(ref mut background) = parsed_options.background { - background[3] = (alpha * 255.0).round() as u8; - } - } - TRIM | TRIM_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid( - "trim option requires at least one argument: threshold", - )); - } - let threshold = parse_float(&option.args[0], "trim threshold")?; - if !threshold.is_finite() || threshold < 0.0 { - return Err(OptionParseError::invalid( - "trim threshold must be a finite, non-negative number", - )); - } - // An empty colour means "work it out from the image", which is - // how imgproxy behaves when the argument is omitted. - let color = match option.args.get(1).filter(|arg| !arg.is_empty()) { - Some(arg) => Some(super::utils::parse_hex_color(arg).map_err(OptionParseError::Color)?), - None => None, - }; - parsed_options.trim = Some(Trim { - threshold: f64::from(threshold), - color, - equal_hor: option - .args - .get(2) - .filter(|arg| !arg.is_empty()) - .map(|arg| super::utils::parse_boolean(arg)) - .unwrap_or(false), - equal_ver: option - .args - .get(3) - .filter(|arg| !arg.is_empty()) - .map(|arg| super::utils::parse_boolean(arg)) - .unwrap_or(false), - }); - } - MAX_RESULT_DIMENSION | MAX_RESULT_DIMENSION_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid( - "max_result_dimension option requires one argument", - )); - } - parsed_options.max_result_dimension = - Some(option.args[0].parse::().map_err(|source| { - OptionParseError::SecurityLimit { - option: "max_result_dimension".to_string(), - source, - } - })?); - } - MAX_SRC_RESOLUTION | MAX_SRC_RESOLUTION_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid( - "max_src_resolution option requires one argument", - )); - } - parsed_options.max_src_resolution = - Some(option.args[0].parse::().map_err(|source| { - OptionParseError::SecurityLimit { - option: "max_src_resolution".to_string(), - source, - } - })?); - } - MAX_SRC_FILE_SIZE | MAX_SRC_FILE_SIZE_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid( - "max_src_file_size option requires one argument", - )); - } - parsed_options.max_src_file_size = - Some(option.args[0].parse::().map_err(|source| { - OptionParseError::SecurityLimit { - option: "max_src_file_size".to_string(), - source, - } - })?); - } - CACHEBUSTER | CACHEBUSTER_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("cachebuster option requires one argument")); - } - parsed_options.cache_buster = Some(option.args[0].clone()); - } - DPR => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("dpr option requires one argument")); - } - let dpr = parse_float(&option.args[0], "dpr")?; - if !(1.0..=5.0).contains(&dpr) { - return Err(OptionParseError::invalid("dpr value must be between 1.0 and 5.0")); - } - parsed_options.dpr = Some(dpr); - } - MIN_WIDTH | MIN_WIDTH_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("min-width option requires one argument")); - } - parsed_options.min_width = Some(parse_integer(&option.args[0], "min-width")?); - } - MIN_HEIGHT | MIN_HEIGHT_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("min-height option requires one argument")); - } - parsed_options.min_height = Some(parse_integer(&option.args[0], "min-height")?); - } - ZOOM | ZOOM_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("zoom option requires one argument")); - } - parsed_options.zoom = Some(parse_positive_f32(&option.args[0], "zoom")?); - } - SHARPEN | SHARPEN_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("sharpen option requires one argument")); - } - parsed_options.sharpen = Some(parse_positive_f32(&option.args[0], "sharpen")?); - } - PIXELATE | PIXELATE_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("pixelate option requires one argument")); - } - parsed_options.pixelate = Some(parse_integer(&option.args[0], "pixelate")?); - } - ADJUST | ADJUST_SHORT => { - let mut adjust = parsed_options.adjust.unwrap_or_default(); - if let Some(arg) = option.args.first().filter(|arg| !arg.is_empty()) { - adjust.brightness = parse_brightness(arg)?; - } - if let Some(arg) = option.args.get(1).filter(|arg| !arg.is_empty()) { - adjust.contrast = parse_positive_f32(arg, "contrast")?; - } - if let Some(arg) = option.args.get(2).filter(|arg| !arg.is_empty()) { - adjust.saturation = parse_positive_f32(arg, "saturation")?; - } - parsed_options.adjust = Some(adjust); - } - BRIGHTNESS | BRIGHTNESS_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("brightness option requires one argument")); - } - let mut adjust = parsed_options.adjust.unwrap_or_default(); - adjust.brightness = parse_brightness(&option.args[0])?; - parsed_options.adjust = Some(adjust); - } - CONTRAST | CONTRAST_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("contrast option requires one argument")); - } - let mut adjust = parsed_options.adjust.unwrap_or_default(); - adjust.contrast = parse_positive_f32(&option.args[0], "contrast")?; - parsed_options.adjust = Some(adjust); - } - SATURATION | SATURATION_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("saturation option requires one argument")); - } - let mut adjust = parsed_options.adjust.unwrap_or_default(); - adjust.saturation = parse_positive_f32(&option.args[0], "saturation")?; - parsed_options.adjust = Some(adjust); - } - WATERMARK | WATERMARK_SHORT => { - if option.args.len() < 2 { - return Err(OptionParseError::invalid( - "watermark option requires two arguments: opacity, position", - )); - } - parsed_options.watermark = Some(Watermark { - opacity: parse_float(&option.args[0], "watermark opacity")?, - position: option.args[1].clone(), - }); - } - WATERMARK_URL | WATERMARK_URL_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("watermark_url option requires one argument")); - } - let decoded_url = decode_base64(&option.args[0], "watermark_url")?; - let url = decode_utf8(decoded_url, "watermark_url")?; - parsed_options.watermark_url = Some(url); - } - RESIZING_ALGORITHM | RESIZING_ALGORITHM_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid( - "resizing_algorithm option requires one argument", - )); - } - let algorithm = option.args[0].to_lowercase(); - if !matches!( - algorithm.as_str(), - "nearest" | "linear" | "cubic" | "lanczos2" | "lanczos3" - ) { - return Err(OptionParseError::invalid(format!( - "Invalid resizing algorithm: {}. Must be one of: nearest, linear, cubic, lanczos2, lanczos3", - algorithm - ))); - } - parsed_options.resizing_algorithm = Some(algorithm); - } - MAX_BYTES | MAX_BYTES_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("max_bytes option requires one argument")); - } - parsed_options.save.max_bytes = Some(parse_integer(&option.args[0], "max_bytes")?); - } - STRIP_METADATA | STRIP_METADATA_SHORT => { - parsed_options.save.strip_metadata = Some( - option - .args - .first() - .map(|arg| super::utils::parse_boolean(arg)) - .unwrap_or(true), - ); - } - STRIP_COLOR_PROFILE | STRIP_COLOR_PROFILE_SHORT => { - parsed_options.save.strip_color_profile = Some( - option - .args - .first() - .map(|arg| super::utils::parse_boolean(arg)) - .unwrap_or(true), - ); - } - JPEG_OPTIONS | JPEG_OPTIONS_SHORT => { - parsed_options.save.jpeg.progressive = parse_optional_bool(&option.args, 0); - parsed_options.save.jpeg.no_subsample = parse_optional_bool(&option.args, 1); - parsed_options.save.jpeg.trellis_quant = parse_optional_bool(&option.args, 2); - parsed_options.save.jpeg.overshoot_deringing = parse_optional_bool(&option.args, 3); - parsed_options.save.jpeg.optimize_scans = parse_optional_bool(&option.args, 4); - if let Some(arg) = option.args.get(5).filter(|arg| !arg.is_empty()) { - parsed_options.save.jpeg.quant_table = Some(parse_integer(arg, "jpeg quant_table")?); - } - } - PNG_OPTIONS | PNG_OPTIONS_SHORT => { - parsed_options.save.png.interlaced = parse_optional_bool(&option.args, 0); - parsed_options.save.png.quantize = parse_optional_bool(&option.args, 1); - if let Some(arg) = option.args.get(2).filter(|arg| !arg.is_empty()) { - parsed_options.save.png.quantization_colors = Some(parse_integer(arg, "png quantization_colors")?); - } - } - WEBP_OPTIONS | WEBP_OPTIONS_SHORT => { - parsed_options.save.webp.lossless = parse_optional_bool(&option.args, 0); - parsed_options.save.webp.smart_subsample = parse_optional_bool(&option.args, 1); - if let Some(arg) = option.args.get(2).filter(|arg| !arg.is_empty()) { - parsed_options.save.webp.preset = Some(arg.to_lowercase()); - } - } - AVIF_OPTIONS | AVIF_OPTIONS_SHORT => { - parsed_options.save.avif.no_subsample = parse_optional_bool(&option.args, 0); - } - PAGE | PAGE_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("page option requires one argument")); - } - parsed_options.page = Some(parse_integer(&option.args[0], "page")?); - } - PAGES | PAGES_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("pages option requires one argument")); - } - parsed_options.pages = Some(parse_integer(&option.args[0], "pages")?); - } - DISABLE_ANIMATION | DISABLE_ANIMATION_SHORT => { - parsed_options.disable_animation = option - .args - .first() - .map(|arg| super::utils::parse_boolean(arg)) - .unwrap_or(true); - } - SKIP_PROCESSING | SKIP_PROCESSING_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid( - "skip_processing option requires at least one argument", - )); - } - parsed_options.skip_processing = option.args.iter().map(|arg| arg.to_lowercase()).collect(); - } - EXPIRES | EXPIRES_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("expires option requires one argument")); - } - parsed_options.expires = Some(parse_integer(&option.args[0], "expires timestamp")?); - } - FILENAME | FILENAME_SHORT => { - if option.args.is_empty() { - return Err(OptionParseError::invalid("filename option requires one argument")); - } - let encoded = option - .args - .get(1) - .map(|arg| super::utils::parse_boolean(arg)) - .unwrap_or(false); - parsed_options.filename = Some(if encoded { - let decoded = decode_base64(&option.args[0], "filename")?; - decode_utf8(decoded, "filename")? - } else { - option.args[0].clone() - }); - } - RETURN_ATTACHMENT | RETURN_ATTACHMENT_SHORT => { - parsed_options.return_attachment = option - .args - .first() - .map(|arg| super::utils::parse_boolean(arg)) - .unwrap_or(true); - } - _ => { - debug!("Unknown option: {}", option.name); - } - } - } - - // Default resize type is `fit` - if parsed_options.resize.is_none() && (parsed_options.width.is_some() || parsed_options.height.is_some()) { - debug!("Applying default 'fit' resize due to width/height options"); - parsed_options.resize = Some(Resize { - resizing_type: "fit".to_string(), - width: parsed_options.width.unwrap_or(0), - height: parsed_options.height.unwrap_or(0), - }); - } - - Ok(parsed_options) -} - -fn parse_brightness(value: &str) -> Result { - let parsed = parse_integer::(value, "brightness")?; - if !(-255..=255).contains(&parsed) { - return Err(OptionParseError::invalid("brightness must be between -255 and 255")); - } - Ok(parsed) -} diff --git a/src/processing/options/effects.rs b/src/processing/options/effects.rs new file mode 100644 index 0000000..e873e33 --- /dev/null +++ b/src/processing/options/effects.rs @@ -0,0 +1,185 @@ +//! Pixel-effect options: colour adjustment, zoom and watermarking. + +use super::error::{arg, parse_float, parse_integer, parse_positive_f32, OptionParseError}; +use super::geometry::GravityType; + +/// Represents the parameters for colour adjustment. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Adjust { + /// Added to every colour channel, in 8-bit units. + pub brightness: i16, + /// Multiplier applied around mid-grey. + pub contrast: f32, + /// Multiplier applied to chroma. + pub saturation: f32, +} + +impl Default for Adjust { + fn default() -> Self { + Self { + brightness: 0, + contrast: 1.0, + saturation: 1.0, + } + } +} + +impl Adjust { + /// Whether this adjustment would change any pixel. + pub fn is_identity(&self) -> bool { + self.brightness == 0 + && (self.contrast - 1.0).abs() <= f32::EPSILON + && (self.saturation - 1.0).abs() <= f32::EPSILON + } +} + +pub(super) fn parse_brightness(value: &str) -> Result { + let parsed = parse_integer::(value, "brightness")?; + if !(-255..=255).contains(&parsed) { + return Err(OptionParseError::invalid("brightness must be between -255 and 255")); + } + Ok(parsed) +} + +/// Independent horizontal and vertical zoom factors. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Zoom { + pub x: f32, + pub y: f32, +} + +impl Default for Zoom { + fn default() -> Self { + Self { x: 1.0, y: 1.0 } + } +} + +impl Zoom { + /// Parses `zoom_x_y` or `zoom_x:zoom_y`. + pub fn parse(args: &[String]) -> Result { + let Some(x) = arg(args, 0) else { + return Err(OptionParseError::invalid("zoom option requires one argument")); + }; + let x = parse_positive_f32(x, "zoom")?; + let y = match arg(args, 1) { + Some(value) => parse_positive_f32(value, "zoom")?, + None => x, + }; + Ok(Self { x, y }) + } + + pub fn is_identity(&self) -> bool { + (self.x - 1.0).abs() <= f32::EPSILON && (self.y - 1.0).abs() <= f32::EPSILON + } + + /// The largest of the two factors, which is what a target size has to be + /// grown by before deciding how far the source may be shrunk on load. + pub fn max_factor(&self) -> f32 { + self.x.max(self.y).max(1.0) + } +} + +/// Where a watermark sits on the image. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum WatermarkPosition { + /// Anchored to one of the nine standard positions. + Anchor(GravityType), + /// Tiled across the whole image. + #[default] + Replicate, +} + +impl WatermarkPosition { + pub fn parse(value: &str) -> Option { + match value { + "re" => Some(Self::Replicate), + other => GravityType::parse(other) + .filter(|kind| *kind != GravityType::FocusPoint) + .map(Self::Anchor), + } + } +} + +/// Represents the parameters for a watermark operation. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Watermark { + /// Opacity multiplier applied to the watermark's own alpha. + pub opacity: f32, + /// Anchor, or `re` to tile. + pub position: WatermarkPosition, + /// Horizontal nudge: pixels at magnitude 1 or more, else a fraction. + pub x_offset: f64, + /// Vertical nudge, read the same way. + pub y_offset: f64, + /// Scale relative to the image's width. Zero keeps the default sizing. + pub scale: f64, +} + +impl Default for Watermark { + fn default() -> Self { + Self { + opacity: 1.0, + position: WatermarkPosition::Anchor(GravityType::Center), + x_offset: 0.0, + y_offset: 0.0, + scale: 0.0, + } + } +} + +impl Watermark { + /// Parses `opacity:position[:x_offset[:y_offset[:scale]]]`. + pub fn parse(args: &[String]) -> Result { + let Some(opacity) = arg(args, 0) else { + return Err(OptionParseError::invalid( + "watermark option requires at least one argument: opacity", + )); + }; + let opacity = parse_float(opacity, "watermark opacity")?; + if !opacity.is_finite() || !(0.0..=1.0).contains(&opacity) { + return Err(OptionParseError::invalid("watermark opacity must be between 0 and 1")); + } + + let position = match arg(args, 1) { + Some(value) => WatermarkPosition::parse(value).ok_or_else(|| { + OptionParseError::invalid( + "watermark position must be one of: ce, no, so, ea, we, noea, nowe, soea, sowe, re", + ) + })?, + None => WatermarkPosition::Anchor(GravityType::Center), + }; + + let x_offset = match arg(args, 2) { + Some(value) => f64::from(parse_float(value, "watermark x offset")?), + None => 0.0, + }; + let y_offset = match arg(args, 3) { + Some(value) => f64::from(parse_float(value, "watermark y offset")?), + None => 0.0, + }; + let scale = match arg(args, 4) { + Some(value) => { + let scale = parse_float(value, "watermark scale")?; + if !scale.is_finite() || scale < 0.0 { + return Err(OptionParseError::invalid( + "watermark scale must be a finite non-negative number", + )); + } + f64::from(scale) + } + None => 0.0, + }; + + if !x_offset.is_finite() || !y_offset.is_finite() { + return Err(OptionParseError::invalid("watermark offsets must be finite numbers")); + } + + Ok(Self { + opacity, + position, + x_offset, + y_offset, + scale, + }) + } +} diff --git a/src/processing/options/encoder.rs b/src/processing/options/encoder.rs new file mode 100644 index 0000000..ed0d10a --- /dev/null +++ b/src/processing/options/encoder.rs @@ -0,0 +1,122 @@ +//! Output options: what the encoder is told, and what metadata survives. + +use super::error::{arg, parse_integer, parse_optional_bool, parse_quality, OptionParseError}; +use std::collections::HashMap; + +/// Encoder-specific output controls. +#[derive(Debug, Clone, Default)] +pub struct SaveOptions { + pub format_quality: HashMap, + pub max_bytes: Option, + pub strip_metadata: Option, + pub strip_color_profile: Option, + /// Retain the copyright tags when metadata is stripped. + pub keep_copyright: Option, + /// Keep a high bit-depth image high bit-depth, and carry any gain map + /// through to the result. + pub preserve_hdr: Option, + pub jpeg: JpegOptions, + pub png: PngOptions, + pub webp: WebpOptions, + pub avif: AvifOptions, +} + +impl SaveOptions { + /// Whether the request asked for any metadata to be dropped. + pub fn strips_anything(&self) -> bool { + self.strip_metadata.unwrap_or(false) || self.strip_color_profile.unwrap_or(false) + } + + /// Whether the copyright tags must be carried across a metadata strip. + pub fn retains_copyright(&self) -> bool { + self.keep_copyright.unwrap_or(false) && self.strip_metadata.unwrap_or(false) + } +} + +/// JPEG encoder controls. +#[derive(Debug, Clone, Default)] +pub struct JpegOptions { + pub progressive: Option, + pub no_subsample: Option, + pub trellis_quant: Option, + pub overshoot_deringing: Option, + pub optimize_scans: Option, + pub quant_table: Option, +} + +/// PNG encoder controls. +#[derive(Debug, Clone, Default)] +pub struct PngOptions { + pub interlaced: Option, + pub quantize: Option, + pub quantization_colors: Option, +} + +/// WebP encoder controls. +#[derive(Debug, Clone, Default)] +pub struct WebpOptions { + pub lossless: Option, + pub smart_subsample: Option, + pub preset: Option, +} + +/// AVIF/HEIF encoder controls. +#[derive(Debug, Clone, Default)] +pub struct AvifOptions { + pub no_subsample: Option, +} + +pub(super) fn parse_format_quality(args: &[String], save: &mut SaveOptions) -> Result<(), OptionParseError> { + if args.len() < 2 || !args.len().is_multiple_of(2) { + return Err(OptionParseError::invalid( + "format_quality option requires format/quality pairs", + )); + } + + for pair in args.chunks_exact(2) { + // Stored under the canonical name, because that is what the lookup uses. + // The output format is canonicalised before it reaches the encoder, so a + // key left as the URL spelled it — `format:tif/format_quality:tif:20` — + // was looked up as `tiff`, missed, and silently fell back to the default + // quality. An unrecognised name is kept as written: it names no format + // imgforge can encode, so it can only ever miss, and rewriting it would + // hide the typo rather than leave it visible in a debug log. + let name = pair[0].to_lowercase(); + let key = crate::processing::save::canonical_format_name(&name) + .map(str::to_owned) + .unwrap_or(name); + save.format_quality + .insert(key, parse_quality(&pair[1], "format_quality")?); + } + + Ok(()) +} + +pub(super) fn parse_jpeg_options(args: &[String], jpeg: &mut JpegOptions) -> Result<(), OptionParseError> { + jpeg.progressive = parse_optional_bool(args, 0); + jpeg.no_subsample = parse_optional_bool(args, 1); + jpeg.trellis_quant = parse_optional_bool(args, 2); + jpeg.overshoot_deringing = parse_optional_bool(args, 3); + jpeg.optimize_scans = parse_optional_bool(args, 4); + if let Some(value) = arg(args, 5) { + jpeg.quant_table = Some(parse_integer(value, "jpeg quant_table")?); + } + Ok(()) +} + +pub(super) fn parse_png_options(args: &[String], png: &mut PngOptions) -> Result<(), OptionParseError> { + png.interlaced = parse_optional_bool(args, 0); + png.quantize = parse_optional_bool(args, 1); + if let Some(value) = arg(args, 2) { + png.quantization_colors = Some(parse_integer(value, "png quantization_colors")?); + } + Ok(()) +} + +pub(super) fn parse_webp_options(args: &[String], webp: &mut WebpOptions) { + webp.lossless = parse_optional_bool(args, 0); + webp.smart_subsample = parse_optional_bool(args, 1); + if let Some(value) = arg(args, 2) { + webp.preset = Some(value.to_lowercase()); + } +} diff --git a/src/processing/options/error.rs b/src/processing/options/error.rs new file mode 100644 index 0000000..d28fbdc --- /dev/null +++ b/src/processing/options/error.rs @@ -0,0 +1,134 @@ +//! Parsing failures and the primitive argument parsers shared by every option +//! group. + +use base64::engine::general_purpose; +use base64::Engine as _; +use std::str::FromStr; +use thiserror::Error; + +/// Errors produced while parsing image processing options. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum OptionParseError { + #[error("invalid {option} value {value:?}")] + Integer { + option: String, + value: String, + #[source] + source: std::num::ParseIntError, + }, + #[error("invalid {option} value {value:?}")] + Float { + option: String, + value: String, + #[source] + source: std::num::ParseFloatError, + }, + #[error("invalid Base64 for {option}")] + Base64 { + option: String, + #[source] + source: base64::DecodeError, + }, + #[error("invalid UTF-8 for {option}")] + Utf8 { + option: String, + #[source] + source: std::string::FromUtf8Error, + }, + #[error("invalid {option}: {source}")] + SecurityLimit { + option: String, + #[source] + source: crate::limits::SecurityLimitError, + }, + #[error("invalid background color")] + Color(#[source] crate::processing::utils::ColorParseError), + #[error("{0}")] + InvalidValue(String), +} + +impl OptionParseError { + pub(crate) fn invalid(message: impl Into) -> Self { + Self::InvalidValue(message.into()) + } +} + +pub(crate) fn parse_integer(value: &str, option: &str) -> Result +where + T: FromStr, +{ + value.parse().map_err(|source| OptionParseError::Integer { + option: option.to_string(), + value: value.to_string(), + source, + }) +} + +pub(crate) fn parse_float(value: &str, option: &str) -> Result { + value.parse().map_err(|source| OptionParseError::Float { + option: option.to_string(), + value: value.to_string(), + source, + }) +} + +pub(crate) fn decode_base64(value: &str, option: &str) -> Result, OptionParseError> { + general_purpose::URL_SAFE_NO_PAD + .decode(value) + .map_err(|source| OptionParseError::Base64 { + option: option.to_string(), + source, + }) +} + +pub(crate) fn decode_utf8(value: Vec, option: &str) -> Result { + String::from_utf8(value).map_err(|source| OptionParseError::Utf8 { + option: option.to_string(), + source, + }) +} + +pub(crate) fn parse_positive_f32(value: &str, option_name: &str) -> Result { + let parsed = parse_float(value, option_name)?; + + if !parsed.is_finite() || parsed <= 0.0 { + return Err(OptionParseError::invalid(format!( + "{} must be a finite positive number", + option_name + ))); + } + + Ok(parsed) +} + +pub(crate) fn parse_unit_f32(value: &str, option_name: &str) -> Result { + let parsed = parse_float(value, option_name)?; + + if !parsed.is_finite() || !(0.0..=1.0).contains(&parsed) { + return Err(OptionParseError::invalid(format!( + "{} must be a finite number between 0 and 1", + option_name + ))); + } + + Ok(parsed) +} + +pub(crate) fn parse_quality(value: &str, option_name: &str) -> Result { + Ok(parse_integer::(value, option_name)?.clamp(1, 100)) +} + +/// Reads argument `index` as a boolean, treating an absent or empty argument as +/// "not specified" rather than `false`. +pub(crate) fn parse_optional_bool(args: &[String], index: usize) -> Option { + args.get(index) + .filter(|arg| !arg.is_empty()) + .map(|arg| crate::processing::utils::parse_boolean(arg)) +} + +/// Reads argument `index`, skipping empty placeholders left by callers who only +/// wanted to set a later positional argument. +pub(crate) fn arg(args: &[String], index: usize) -> Option<&str> { + args.get(index).map(String::as_str).filter(|value| !value.is_empty()) +} diff --git a/src/processing/options/geometry.rs b/src/processing/options/geometry.rs new file mode 100644 index 0000000..f23de6a --- /dev/null +++ b/src/processing/options/geometry.rs @@ -0,0 +1,317 @@ +//! Geometry options: resizing, gravity, cropping, extending, trimming and +//! flipping. + +use super::error::{arg, parse_float, parse_integer, OptionParseError}; +use crate::processing::utils::parse_boolean; +use std::str::FromStr; + +/// How a resize maps the source onto the requested box. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ResizingType { + /// Keep the aspect ratio and fit inside the box. + #[default] + Fit, + /// Keep the aspect ratio, cover the box, and crop what projects out. + Fill, + /// `Fill`, except that a result smaller than the box is cropped to the + /// box's aspect ratio rather than padded out to its size. + FillDown, + /// Ignore the aspect ratio and hit the box exactly. + Force, + /// `Fill` when the source and the box share an orientation, `Fit` otherwise. + Auto, +} + +/// Rejected value for [`ResizingType`], carrying the list of what is accepted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResizingTypeParseError; + +impl FromStr for ResizingType { + type Err = ResizingTypeParseError; + + fn from_str(value: &str) -> Result { + match value { + "fit" => Ok(Self::Fit), + "fill" => Ok(Self::Fill), + "fill-down" | "fill_down" => Ok(Self::FillDown), + "force" => Ok(Self::Force), + "auto" => Ok(Self::Auto), + _ => Err(ResizingTypeParseError), + } + } +} + +impl ResizingType { + pub const fn as_str(self) -> &'static str { + match self { + Self::Fit => "fit", + Self::Fill => "fill", + Self::FillDown => "fill-down", + Self::Force => "force", + Self::Auto => "auto", + } + } + + /// Whether a zero axis is taken from the source rather than derived from + /// the aspect ratio. Only `force` does that, which is why it is the one + /// resizing type scale-on-load has to leave a full-size axis for. + pub const fn fills_zero_axis_from_source(self) -> bool { + matches!(self, Self::Force) + } +} + +/// Represents the parameters for a resize operation. +#[derive(Debug, Default, Clone, Copy)] +pub struct Resize { + /// The type of resizing to perform. + pub resizing_type: ResizingType, + /// The target width for the resize operation. + pub width: u32, + /// The target height for the resize operation. + pub height: u32, +} + +/// Anchor used when an operation has to choose which part of an image to keep. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum GravityType { + #[default] + Center, + North, + South, + East, + West, + NorthEast, + NorthWest, + SouthEast, + SouthWest, + /// The offsets name a point, in 0..1 of each axis, to centre the result on. + FocusPoint, +} + +impl GravityType { + pub fn parse(value: &str) -> Option { + match value { + "ce" => Some(Self::Center), + "no" => Some(Self::North), + "so" => Some(Self::South), + "ea" => Some(Self::East), + "we" => Some(Self::West), + "noea" => Some(Self::NorthEast), + "nowe" => Some(Self::NorthWest), + "soea" => Some(Self::SouthEast), + "sowe" => Some(Self::SouthWest), + "fp" => Some(Self::FocusPoint), + _ => None, + } + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Center => "ce", + Self::North => "no", + Self::South => "so", + Self::East => "ea", + Self::West => "we", + Self::NorthEast => "noea", + Self::NorthWest => "nowe", + Self::SouthEast => "soea", + Self::SouthWest => "sowe", + Self::FocusPoint => "fp", + } + } +} + +/// An anchor plus its offsets. +/// +/// For every anchor but [`GravityType::FocusPoint`] the offsets nudge the +/// window away from the anchor: an absolute pixel count when the magnitude is +/// at least 1, otherwise a fraction of the axis being positioned. Focus point +/// instead reads them as the coordinates, in 0..1, that the result centres on. +/// Both readings come from imgproxy, whose `calcPosition` this mirrors. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Gravity { + pub kind: GravityType, + pub x: f64, + pub y: f64, +} + +impl Default for Gravity { + fn default() -> Self { + Self::new(GravityType::Center) + } +} + +impl Gravity { + pub const fn new(kind: GravityType) -> Self { + Self { kind, x: 0.0, y: 0.0 } + } + + /// Parses `type[:x_offset[:y_offset]]` starting at `start` in `args`. + pub fn parse(args: &[String], start: usize, option: &'static str) -> Result { + let Some(kind) = arg(args, start) else { + return Err(OptionParseError::invalid(format!( + "{option} gravity requires a gravity type" + ))); + }; + let kind = GravityType::parse(kind).ok_or_else(|| { + OptionParseError::invalid(format!( + "{option} gravity must be one of: ce, no, so, ea, we, noea, nowe, soea, sowe, fp" + )) + })?; + + let x = match arg(args, start + 1) { + Some(value) => f64::from(parse_float(value, "gravity x offset")?), + None => 0.0, + }; + let y = match arg(args, start + 2) { + Some(value) => f64::from(parse_float(value, "gravity y offset")?), + None => 0.0, + }; + + if !x.is_finite() || !y.is_finite() { + return Err(OptionParseError::invalid("gravity offsets must be finite numbers")); + } + + if kind == GravityType::FocusPoint && (!(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y)) { + return Err(OptionParseError::invalid( + "focus point gravity coordinates must be between 0 and 1", + )); + } + + Ok(Self { kind, x, y }) + } +} + +/// Represents the parameters for a crop operation. +/// +/// Extents follow imgproxy: a value of at least 1 is a pixel count, a value +/// below 1 is a fraction of the source axis, and 0 means "the whole axis". +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct Crop { + pub width: f64, + pub height: f64, + /// Optional crop gravity, falling back to the request's own `gravity`. + pub gravity: Option, +} + +impl Crop { + /// Resolves one extent against the source axis it is measured on. + pub fn resolve_extent(extent: f64, source: u32) -> u32 { + if extent <= 0.0 || !extent.is_finite() { + return 0; + } + if extent >= 1.0 { + return extent as u32; + } + ((f64::from(source) * extent).round() as u32).max(1) + } + + /// The pixel extents this crop resolves to against a given source. + pub fn resolve(&self, src_width: u32, src_height: u32) -> (u32, u32) { + ( + Self::resolve_extent(self.width, src_width), + Self::resolve_extent(self.height, src_height), + ) + } +} + +/// Padding out to a target size (`extend`) or to a target aspect ratio +/// (`extend_aspect_ratio`). +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct Extend { + pub enabled: bool, + /// Where the source sits on the extended canvas, falling back to centre. + pub gravity: Option, +} + +impl Extend { + /// Parses `enabled[:gravity_type[:x[:y]]]`. + pub fn parse(args: &[String], option: &'static str) -> Result { + let enabled = arg(args, 0).map(parse_boolean).unwrap_or(false); + let gravity = match arg(args, 1) { + Some(_) => Some(Gravity::parse(args, 1, option)?), + None => None, + }; + Ok(Self { enabled, gravity }) + } +} + +/// Represents the parameters for a flip operation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Flip { + pub horizontal: bool, + pub vertical: bool, +} + +/// Border trimming controls. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Trim { + /// How far a pixel may differ from the background and still be trimmed. + pub threshold: f64, + /// Colour to treat as background. Detected from the top-left pixel when absent. + pub color: Option<[u8; 4]>, + /// Cut equal amounts from the left and right. + pub equal_hor: bool, + /// Cut equal amounts from the top and bottom. + pub equal_ver: bool, +} + +/// Parses `padding:top[:right[:bottom[:left]]]` into (top, right, bottom, left). +pub(super) fn parse_padding(args: &[String]) -> Result<(u32, u32, u32, u32), OptionParseError> { + if args.is_empty() { + return Err(OptionParseError::invalid( + "padding option requires at least one argument", + )); + } + let values: Vec = args + .iter() + .map(|value| parse_integer(value, "padding")) + .collect::, _>>()?; + + Ok(match values.len() { + 1 => (values[0], values[0], values[0], values[0]), + 2 => (values[0], values[1], values[0], values[1]), + 3 => (values[0], values[1], values[2], values[1]), + 4 => (values[0], values[1], values[2], values[3]), + _ => return Err(OptionParseError::invalid("padding must have 1 to 4 arguments")), + }) +} + +/// Parses the `trim` arguments. +pub(super) fn parse_trim(args: &[String]) -> Result { + let Some(threshold) = arg(args, 0) else { + return Err(OptionParseError::invalid( + "trim option requires at least one argument: threshold", + )); + }; + let threshold = parse_float(threshold, "trim threshold")?; + if !threshold.is_finite() || threshold < 0.0 { + return Err(OptionParseError::invalid( + "trim threshold must be a finite, non-negative number", + )); + } + + // An empty colour means "work it out from the image", which is how imgproxy + // behaves when the argument is omitted. + let color = match arg(args, 1) { + Some(value) => Some(crate::processing::utils::parse_hex_color(value).map_err(OptionParseError::Color)?), + None => None, + }; + + Ok(Trim { + threshold: f64::from(threshold), + color, + equal_hor: arg(args, 2).map(parse_boolean).unwrap_or(false), + equal_ver: arg(args, 3).map(parse_boolean).unwrap_or(false), + }) +} + +const VALID_ROTATIONS: [u16; 4] = [0, 90, 180, 270]; + +pub(super) fn parse_rotation(value: &str) -> Result { + let rotation = parse_integer(value, "rotation")?; + if !VALID_ROTATIONS.contains(&rotation) { + return Err(OptionParseError::invalid("rotation must be one of: 0, 90, 180, 270")); + } + Ok(rotation) +} diff --git a/src/processing/options/mod.rs b/src/processing/options/mod.rs new file mode 100644 index 0000000..685fe92 --- /dev/null +++ b/src/processing/options/mod.rs @@ -0,0 +1,687 @@ +//! Parsing of the imgproxy-compatible processing directives carried in the URL +//! path. +//! +//! The directive table lives in [`names`]; each option group owns its own types +//! and argument parsing, and [`parse_all_options`] is the dispatch that binds a +//! directive name to the group that understands it. + +mod effects; +mod encoder; +mod error; +mod geometry; +mod names; + +pub use effects::{Adjust, Watermark, WatermarkPosition, Zoom}; +pub use encoder::{AvifOptions, JpegOptions, PngOptions, SaveOptions, WebpOptions}; +pub use error::OptionParseError; +pub use geometry::{Crop, Extend, Flip, Gravity, GravityType, Resize, ResizingType, Trim}; + +use crate::limits::{ + MaxAnimationFrameResolution, MaxAnimationFrames, MaxResultDimension, MaxSourceFileSize, MaxSourceResolution, +}; +use crate::processing::utils::parse_boolean; +use error::{arg, decode_base64, decode_utf8, parse_integer, parse_positive_f32, parse_quality, parse_unit_f32}; +use names::*; +use std::str::FromStr; +use tracing::debug; + +/// Represents a single image processing option from the URL path. +#[derive(Debug, Clone)] +pub struct ProcessingOption { + /// The name of the processing option (e.g., "resize", "quality"). + pub name: String, + /// Arguments for the processing option. + pub args: Vec, +} + +/// Holds all parsed image processing options. +#[derive(Debug)] +pub struct ParsedOptions { + /// Optional resize operation parameters. + pub resize: Option, + /// Optional blur sigma value. + pub blur: Option, + /// Optional crop operation parameters. + pub crop: Option, + /// Optional output image format. + pub format: Option, + /// Optional output image quality (1-100). + pub quality: Option, + /// Optional background color for transparent areas or extending. + pub background: Option<[u8; 4]>, // RGBA array + /// Optional target width (used with `resize` if no explicit resize type). + pub width: Option, + /// Optional target height (used with `resize` if no explicit resize type). + pub height: Option, + /// Optional gravity for cropping or filling. + pub gravity: Option, + /// Whether to allow enlarging the image beyond its original dimensions. + pub enlarge: bool, + /// Padding out to the requested dimensions after resizing. + pub extend: Extend, + /// Padding out to the requested aspect ratio after resizing. + pub extend_aspect_ratio: Extend, + /// Optional padding values (top, right, bottom, left). + pub padding: Option<(u32, u32, u32, u32)>, + /// Optional image rotation (rotation angle). + pub rotation: Option, + /// Optional flip operation. + pub flip: Option, + /// Whether to automatically rotate the image based on EXIF data. + pub auto_rotate: bool, + /// Whether to return the source untouched. + pub raw: bool, + /// Maximum allowed source image resolution in megapixels. + pub max_src_resolution: Option, + /// Ceiling for either dimension of the processed image. + pub max_result_dimension: Option, + /// Ceiling on how many frames of an animated source are decoded. + pub max_animation_frames: Option, + /// Ceiling on the pixel count of a single animation frame. + pub max_animation_frame_resolution: Option, + /// Border trimming, applied before crop and resize. + pub trim: Option, + /// Maximum allowed source image file size in bytes. + pub max_src_file_size: Option, + /// Value to bypass cache (e.g., timestamp). + pub cache_buster: Option, + /// Optional unix timestamp after which the request expires. + pub expires: Option, + /// Optional response filename for Content-Disposition. + pub filename: Option, + /// Whether to return Content-Disposition as attachment. + pub return_attachment: bool, + /// Device pixel ratio factor to scale up dimensions. + pub dpr: Option, + /// Minimum width for the image. + pub min_width: Option, + /// Minimum height for the image. + pub min_height: Option, + /// Zoom factors applied after resizing. + pub zoom: Option, + /// Sharpen factor for the image. + pub sharpen: Option, + /// Pixelate factor for the image. + pub pixelate: Option, + /// Watermark placement, when a watermark source is configured. + pub watermark: Option, + /// Optional URL for a watermark image. + pub watermark_url: Option, + /// Resizing algorithm to use (nearest, linear, cubic, lanczos2, lanczos3). + pub resizing_algorithm: Option, + /// Optional alpha value applied to background. + pub background_alpha: Option, + /// Optional color adjustments. + pub adjust: Option, + /// Encoder-specific output options. + pub save: SaveOptions, + /// Prefer the source's embedded thumbnail when one is large enough. + pub enforce_thumbnail: bool, + /// First page of a multi-page source to read. + pub page: Option, + /// How many pages of a multi-page source to read. + pub pages: Option, + /// Whether to collapse an animated source to its first frame. + pub disable_animation: bool, + /// Source formats that may bypass processing when output format matches. + pub skip_processing: Vec, +} + +/// Server-configured starting values for the options a URL may override. +/// +/// imgproxy lets an operator set the default for `auto_rotate`, +/// `strip_metadata` and friends, with the URL overriding it. Seeding the parse +/// rather than patching the result afterwards keeps that a single rule: the +/// URL always wins because it is applied second, and no option needs a separate +/// "was this set?" flag alongside its value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OptionDefaults { + pub auto_rotate: bool, + pub strip_metadata: bool, + pub keep_copyright: bool, + pub strip_color_profile: bool, + pub preserve_hdr: bool, + pub enforce_thumbnail: bool, + pub return_attachment: bool, + pub quality: Option, +} + +impl Default for OptionDefaults { + fn default() -> Self { + Self { + auto_rotate: true, + strip_metadata: false, + keep_copyright: false, + strip_color_profile: false, + preserve_hdr: false, + enforce_thumbnail: false, + return_attachment: false, + quality: None, + } + } +} + +impl Default for ParsedOptions { + fn default() -> Self { + Self::with_defaults(OptionDefaults::default()) + } +} + +impl ParsedOptions { + /// Builds the starting point for a parse, seeded from the server's + /// configured defaults. + pub fn with_defaults(defaults: OptionDefaults) -> Self { + Self { + resize: None, + blur: None, + crop: None, + format: None, + quality: defaults.quality, + background: None, + width: None, + height: None, + gravity: None, + enlarge: false, + extend: Extend::default(), + extend_aspect_ratio: Extend::default(), + padding: None, + rotation: None, + flip: None, + auto_rotate: defaults.auto_rotate, + raw: false, + max_src_resolution: None, + max_result_dimension: None, + max_animation_frames: None, + max_animation_frame_resolution: None, + trim: None, + max_src_file_size: None, + cache_buster: None, + expires: None, + filename: None, + return_attachment: defaults.return_attachment, + dpr: Some(1.0), + min_width: None, + min_height: None, + zoom: None, + sharpen: None, + pixelate: None, + watermark: None, + watermark_url: None, + resizing_algorithm: Some("lanczos3".to_string()), + background_alpha: None, + adjust: None, + save: SaveOptions { + strip_metadata: Some(defaults.strip_metadata), + strip_color_profile: Some(defaults.strip_color_profile), + keep_copyright: Some(defaults.keep_copyright), + preserve_hdr: Some(defaults.preserve_hdr), + ..SaveOptions::default() + }, + enforce_thumbnail: defaults.enforce_thumbnail, + page: None, + pages: None, + disable_animation: false, + skip_processing: Vec::new(), + } + } + + /// The zoom factors in effect, defaulting to no zoom. + pub fn zoom_factors(&self) -> Zoom { + self.zoom.unwrap_or_default() + } + + /// The device pixel ratio in effect, never below 1. + pub fn dpr_factor(&self) -> f32 { + self.dpr.unwrap_or(1.0).max(1.0) + } + + /// Gravity for a crop: the crop's own if it names one, otherwise the + /// request's `gravity`, otherwise centre. imgproxy resolves it the same way. + pub fn crop_gravity(&self) -> Gravity { + self.crop + .and_then(|crop| crop.gravity) + .or(self.gravity) + .unwrap_or_default() + } + + /// Gravity for the fill window, which never consults the crop's. + pub fn fill_gravity(&self) -> Gravity { + self.gravity.unwrap_or_default() + } +} + +/// Parses a vector of `ProcessingOption` into a `ParsedOptions` struct. +/// +/// This function iterates through the raw processing options, validates their arguments, +/// and converts them into a structured `ParsedOptions` object. +/// +/// # Arguments +/// +/// * `options` - A `Vec` containing the raw options from the URL. +/// +/// # Returns +/// +/// A `Result` containing the `ParsedOptions` on success, or a typed parsing error. +pub fn parse_all_options(options: Vec) -> Result { + parse_all_options_with_defaults(options, OptionDefaults::default()) +} + +/// Parses processing options on top of the server's configured defaults. +pub fn parse_all_options_with_defaults( + options: Vec, + defaults: OptionDefaults, +) -> Result { + let mut parsed = ParsedOptions::with_defaults(defaults); + + for option in options { + debug!("Parsing option: {} with args: {:?}", option.name, option.args); + apply_option(&option, &mut parsed)?; + } + + // Default resize type is `fit` + if parsed.resize.is_none() && (parsed.width.is_some() || parsed.height.is_some()) { + debug!("Applying default 'fit' resize due to width/height options"); + parsed.resize = Some(Resize { + resizing_type: ResizingType::Fit, + width: parsed.width.unwrap_or(0), + height: parsed.height.unwrap_or(0), + }); + } + + Ok(parsed) +} + +fn apply_option(option: &ProcessingOption, parsed: &mut ParsedOptions) -> Result<(), OptionParseError> { + let args = option.args.as_slice(); + + match option.name.as_str() { + RESIZE | RESIZE_SHORT => apply_resize(args, parsed)?, + RESIZING_TYPE | RESIZING_TYPE_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("resizing_type option requires one argument"))?; + parsed.resize.get_or_insert_with(Resize::default).resizing_type = parse_resizing_type(value)?; + } + SIZE | SIZE_SHORT => apply_size(args, parsed)?, + WIDTH | WIDTH_SHORT => { + parsed.width = Some(match arg(args, 0) { + Some(value) => parse_integer(value, "width")?, + None => 0, + }); + } + HEIGHT | HEIGHT_SHORT => { + parsed.height = Some(match arg(args, 0) { + Some(value) => parse_integer(value, "height")?, + None => 0, + }); + } + GRAVITY | GRAVITY_SHORT => parsed.gravity = Some(Gravity::parse(args, 0, "gravity")?), + ENLARGE | ENLARGE_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("enlarge option requires one argument"))?; + parsed.enlarge = parse_boolean(value); + } + EXTEND | EXTEND_SHORT => { + if args.is_empty() { + return Err(OptionParseError::invalid("extend option requires one argument")); + } + parsed.extend = Extend::parse(args, "extend")?; + } + EXTEND_ASPECT_RATIO | EXTEND_ASPECT_RATIO_ALT | EXTEND_ASPECT_RATIO_SHORT => { + if args.is_empty() { + return Err(OptionParseError::invalid( + "extend_aspect_ratio option requires one argument", + )); + } + parsed.extend_aspect_ratio = Extend::parse(args, "extend_aspect_ratio")?; + } + PADDING | PADDING_SHORT => parsed.padding = Some(geometry::parse_padding(args)?), + ROTATE | ROTATE_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("rotation option requires one argument"))?; + parsed.rotation = Some(geometry::parse_rotation(value)?); + } + FLIP | FLIP_SHORT => { + parsed.flip = Some(Flip { + horizontal: error::parse_optional_bool(args, 0).unwrap_or(false), + vertical: error::parse_optional_bool(args, 1).unwrap_or(false), + }); + } + AUTO_ROTATE | AUTO_ROTATE_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("auto_rotate option requires one argument"))?; + parsed.auto_rotate = parse_boolean(value); + } + RAW => parsed.raw = arg(args, 0).map(parse_boolean).unwrap_or(true), + BLUR | BLUR_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("blur option requires one argument: sigma"))?; + parsed.blur = Some(parse_positive_f32(value, "blur")?); + } + CROP | CROP_SHORT => parsed.crop = Some(parse_crop(args)?), + FORMAT | FORMAT_SHORT | FORMAT_EXT => { + let value = arg(args, 0).ok_or_else(|| OptionParseError::invalid("format option requires one argument"))?; + parsed.format = Some(value.to_lowercase()); + } + QUALITY | QUALITY_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("quality option requires one argument"))?; + parsed.quality = Some(parse_quality(value, "quality")?); + } + FORMAT_QUALITY | FORMAT_QUALITY_SHORT => encoder::parse_format_quality(args, &mut parsed.save)?, + BACKGROUND | BACKGROUND_SHORT => apply_background(args, parsed)?, + BACKGROUND_ALPHA | BACKGROUND_ALPHA_SHORT => { + let value = arg(args, 0) + .ok_or_else(|| OptionParseError::invalid("background_alpha option requires one argument"))?; + let alpha = parse_unit_f32(value, "background_alpha")?; + parsed.background_alpha = Some(alpha); + if let Some(background) = parsed.background.as_mut() { + background[3] = (alpha * 255.0).round() as u8; + } + } + TRIM | TRIM_SHORT => parsed.trim = Some(geometry::parse_trim(args)?), + MAX_RESULT_DIMENSION | MAX_RESULT_DIMENSION_SHORT => { + parsed.max_result_dimension = Some(parse_limit(args, "max_result_dimension")?); + } + MAX_SRC_RESOLUTION | MAX_SRC_RESOLUTION_SHORT => { + parsed.max_src_resolution = Some(parse_limit(args, "max_src_resolution")?); + } + MAX_SRC_FILE_SIZE | MAX_SRC_FILE_SIZE_SHORT => { + parsed.max_src_file_size = Some(parse_limit(args, "max_src_file_size")?); + } + MAX_ANIMATION_FRAMES | MAX_ANIMATION_FRAMES_SHORT => { + parsed.max_animation_frames = Some(parse_limit(args, "max_animation_frames")?); + } + MAX_ANIMATION_FRAME_RESOLUTION | MAX_ANIMATION_FRAME_RESOLUTION_SHORT => { + parsed.max_animation_frame_resolution = Some(parse_limit(args, "max_animation_frame_resolution")?); + } + CACHEBUSTER | CACHEBUSTER_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("cachebuster option requires one argument"))?; + parsed.cache_buster = Some(value.to_string()); + } + DPR => { + let value = arg(args, 0).ok_or_else(|| OptionParseError::invalid("dpr option requires one argument"))?; + let dpr = error::parse_float(value, "dpr")?; + if !(1.0..=5.0).contains(&dpr) { + return Err(OptionParseError::invalid("dpr value must be between 1.0 and 5.0")); + } + parsed.dpr = Some(dpr); + } + MIN_WIDTH | MIN_WIDTH_ALT | MIN_WIDTH_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("min-width option requires one argument"))?; + parsed.min_width = Some(parse_integer(value, "min-width")?); + } + MIN_HEIGHT | MIN_HEIGHT_ALT | MIN_HEIGHT_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("min-height option requires one argument"))?; + parsed.min_height = Some(parse_integer(value, "min-height")?); + } + ZOOM | ZOOM_SHORT => parsed.zoom = Some(Zoom::parse(args)?), + SHARPEN | SHARPEN_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("sharpen option requires one argument"))?; + parsed.sharpen = Some(parse_positive_f32(value, "sharpen")?); + } + PIXELATE | PIXELATE_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("pixelate option requires one argument"))?; + parsed.pixelate = Some(parse_integer(value, "pixelate")?); + } + ADJUST | ADJUST_SHORT => { + let mut adjust = parsed.adjust.unwrap_or_default(); + if let Some(value) = arg(args, 0) { + adjust.brightness = effects::parse_brightness(value)?; + } + if let Some(value) = arg(args, 1) { + adjust.contrast = parse_positive_f32(value, "contrast")?; + } + if let Some(value) = arg(args, 2) { + adjust.saturation = parse_positive_f32(value, "saturation")?; + } + parsed.adjust = Some(adjust); + } + BRIGHTNESS | BRIGHTNESS_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("brightness option requires one argument"))?; + let mut adjust = parsed.adjust.unwrap_or_default(); + adjust.brightness = effects::parse_brightness(value)?; + parsed.adjust = Some(adjust); + } + CONTRAST | CONTRAST_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("contrast option requires one argument"))?; + let mut adjust = parsed.adjust.unwrap_or_default(); + adjust.contrast = parse_positive_f32(value, "contrast")?; + parsed.adjust = Some(adjust); + } + SATURATION | SATURATION_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("saturation option requires one argument"))?; + let mut adjust = parsed.adjust.unwrap_or_default(); + adjust.saturation = parse_positive_f32(value, "saturation")?; + parsed.adjust = Some(adjust); + } + WATERMARK | WATERMARK_SHORT => parsed.watermark = Some(Watermark::parse(args)?), + WATERMARK_URL | WATERMARK_URL_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("watermark_url option requires one argument"))?; + let decoded = decode_base64(value, "watermark_url")?; + parsed.watermark_url = Some(decode_utf8(decoded, "watermark_url")?); + } + RESIZING_ALGORITHM | RESIZING_ALGORITHM_SHORT => { + let value = arg(args, 0) + .ok_or_else(|| OptionParseError::invalid("resizing_algorithm option requires one argument"))?; + let algorithm = value.to_lowercase(); + if !matches!( + algorithm.as_str(), + "nearest" | "linear" | "cubic" | "lanczos2" | "lanczos3" + ) { + return Err(OptionParseError::invalid(format!( + "Invalid resizing algorithm: {}. Must be one of: nearest, linear, cubic, lanczos2, lanczos3", + algorithm + ))); + } + parsed.resizing_algorithm = Some(algorithm); + } + MAX_BYTES | MAX_BYTES_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("max_bytes option requires one argument"))?; + parsed.save.max_bytes = Some(parse_integer(value, "max_bytes")?); + } + STRIP_METADATA | STRIP_METADATA_SHORT => { + parsed.save.strip_metadata = Some(arg(args, 0).map(parse_boolean).unwrap_or(true)); + } + KEEP_COPYRIGHT | KEEP_COPYRIGHT_SHORT => { + parsed.save.keep_copyright = Some(arg(args, 0).map(parse_boolean).unwrap_or(true)); + } + STRIP_COLOR_PROFILE | STRIP_COLOR_PROFILE_SHORT => { + parsed.save.strip_color_profile = Some(arg(args, 0).map(parse_boolean).unwrap_or(true)); + } + PRESERVE_HDR | PRESERVE_HDR_SHORT => { + parsed.save.preserve_hdr = Some(arg(args, 0).map(parse_boolean).unwrap_or(true)); + } + ENFORCE_THUMBNAIL | ENFORCE_THUMBNAIL_SHORT => { + parsed.enforce_thumbnail = arg(args, 0).map(parse_boolean).unwrap_or(true); + } + JPEG_OPTIONS | JPEG_OPTIONS_SHORT => encoder::parse_jpeg_options(args, &mut parsed.save.jpeg)?, + PNG_OPTIONS | PNG_OPTIONS_SHORT => encoder::parse_png_options(args, &mut parsed.save.png)?, + WEBP_OPTIONS | WEBP_OPTIONS_SHORT => encoder::parse_webp_options(args, &mut parsed.save.webp), + AVIF_OPTIONS | AVIF_OPTIONS_SHORT => { + parsed.save.avif.no_subsample = error::parse_optional_bool(args, 0); + } + PAGE | PAGE_SHORT => { + let value = arg(args, 0).ok_or_else(|| OptionParseError::invalid("page option requires one argument"))?; + parsed.page = Some(parse_integer(value, "page")?); + } + PAGES | PAGES_SHORT => { + let value = arg(args, 0).ok_or_else(|| OptionParseError::invalid("pages option requires one argument"))?; + let pages: u32 = parse_integer(value, "pages")?; + if pages == 0 { + return Err(OptionParseError::invalid("pages must be greater than zero")); + } + parsed.pages = Some(pages); + } + DISABLE_ANIMATION | DISABLE_ANIMATION_SHORT => { + parsed.disable_animation = arg(args, 0).map(parse_boolean).unwrap_or(true); + } + SKIP_PROCESSING | SKIP_PROCESSING_SHORT => { + if args.is_empty() { + return Err(OptionParseError::invalid( + "skip_processing option requires at least one argument", + )); + } + parsed.skip_processing = args.iter().map(|value| value.to_lowercase()).collect(); + } + EXPIRES | EXPIRES_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("expires option requires one argument"))?; + parsed.expires = Some(parse_integer(value, "expires timestamp")?); + } + FILENAME | FILENAME_SHORT => { + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid("filename option requires one argument"))?; + let encoded = arg(args, 1).map(parse_boolean).unwrap_or(false); + parsed.filename = Some(if encoded { + decode_utf8(decode_base64(value, "filename")?, "filename")? + } else { + value.to_string() + }); + } + RETURN_ATTACHMENT | RETURN_ATTACHMENT_SHORT => { + parsed.return_attachment = arg(args, 0).map(parse_boolean).unwrap_or(true); + } + unknown => debug!("Unknown option: {}", unknown), + } + + Ok(()) +} + +fn parse_resizing_type(value: &str) -> Result { + value + .parse() + .map_err(|_| OptionParseError::invalid("resizing_type must be one of: fit, fill, fill-down, force, auto")) +} + +/// A limit option always takes exactly one argument and validates it through +/// the same type the configuration uses, so a URL override cannot be looser +/// than what the server would have accepted. +fn parse_limit(args: &[String], option: &'static str) -> Result +where + T: FromStr, +{ + let value = + arg(args, 0).ok_or_else(|| OptionParseError::invalid(format!("{option} option requires one argument")))?; + value.parse().map_err(|source| OptionParseError::SecurityLimit { + option: option.to_string(), + source, + }) +} + +fn apply_resize(args: &[String], parsed: &mut ParsedOptions) -> Result<(), OptionParseError> { + let mut store_resize = parsed.resize.is_some(); + let mut resize = parsed.resize.take().unwrap_or_default(); + + if let Some(value) = arg(args, 0) { + resize.resizing_type = parse_resizing_type(value)?; + store_resize = true; + } + if let Some(value) = arg(args, 1) { + resize.width = parse_integer(value, "resize width")?; + store_resize = true; + } + if let Some(value) = arg(args, 2) { + resize.height = parse_integer(value, "resize height")?; + store_resize = true; + } + if let Some(value) = arg(args, 3) { + parsed.enlarge = parse_boolean(value); + } + if arg(args, 4).is_some() { + parsed.extend = Extend::parse(&args[4..], "extend")?; + } + + if store_resize { + parsed.resize = Some(resize); + } + + Ok(()) +} + +fn apply_size(args: &[String], parsed: &mut ParsedOptions) -> Result<(), OptionParseError> { + let mut store_resize = parsed.resize.is_some(); + let mut resize = parsed.resize.take().unwrap_or_default(); + + if let Some(value) = arg(args, 0) { + resize.width = parse_integer(value, "size width")?; + store_resize = true; + } + if let Some(value) = arg(args, 1) { + resize.height = parse_integer(value, "size height")?; + store_resize = true; + } + if let Some(value) = arg(args, 2) { + parsed.enlarge = parse_boolean(value); + } + if arg(args, 3).is_some() { + parsed.extend = Extend::parse(&args[3..], "extend")?; + } + + if store_resize { + parsed.resize = Some(resize); + } + + Ok(()) +} + +fn apply_background(args: &[String], parsed: &mut ParsedOptions) -> Result<(), OptionParseError> { + if args.is_empty() || args.iter().all(|value| value.is_empty()) { + parsed.background = None; + return Ok(()); + } + + let mut background = if args.len() >= 3 { + [ + parse_integer(&args[0], "background red channel")?, + parse_integer(&args[1], "background green channel")?, + parse_integer(&args[2], "background blue channel")?, + 255, + ] + } else { + crate::processing::utils::parse_hex_color(&args[0]).map_err(OptionParseError::Color)? + }; + + if let Some(alpha) = parsed.background_alpha { + background[3] = (alpha * 255.0).round() as u8; + } + parsed.background = Some(background); + + Ok(()) +} + +fn parse_crop(args: &[String]) -> Result { + if args.len() < 2 { + return Err(OptionParseError::invalid( + "crop option requires at least two arguments: width, height", + )); + } + + let width = parse_crop_extent(&args[0], "crop width")?; + let height = parse_crop_extent(&args[1], "crop height")?; + let gravity = match arg(args, 2) { + Some(_) => Some(Gravity::parse(args, 2, "crop")?), + None => None, + }; + + Ok(Crop { width, height, gravity }) +} + +fn parse_crop_extent(value: &str, option: &str) -> Result { + let extent = f64::from(error::parse_float(value, option)?); + if !extent.is_finite() || extent < 0.0 { + return Err(OptionParseError::invalid(format!( + "{option} must be a finite non-negative number" + ))); + } + Ok(extent) +} diff --git a/src/processing/options/names.rs b/src/processing/options/names.rs new file mode 100644 index 0000000..4cc367b --- /dev/null +++ b/src/processing/options/names.rs @@ -0,0 +1,242 @@ +//! URL directive names and their imgproxy-compatible aliases. +//! +//! Kept in one place so the dispatch table in [`super::parse_all_options`] and +//! the documentation stay in step: a new option is a constant here plus an arm +//! there. + +/// Option name for resizing. +pub(super) const RESIZE: &str = "resize"; +/// Shorthand for resize. +pub(super) const RESIZE_SHORT: &str = "rs"; +/// Option name for resizing type. +pub(super) const RESIZING_TYPE: &str = "resizing_type"; +/// Shorthand for resizing type. +pub(super) const RESIZING_TYPE_SHORT: &str = "rt"; +/// Option name for size. +pub(super) const SIZE: &str = "size"; +/// Shorthand for size. +pub(super) const SIZE_SHORT: &str = "s"; +/// Option name for width. +pub(super) const WIDTH: &str = "width"; +/// Shorthand for width. +pub(super) const WIDTH_SHORT: &str = "w"; +/// Option name for height. +pub(super) const HEIGHT: &str = "height"; +/// Shorthand for height. +pub(super) const HEIGHT_SHORT: &str = "h"; +/// Option name for gravity. +pub(super) const GRAVITY: &str = "gravity"; +/// Shorthand for gravity. +pub(super) const GRAVITY_SHORT: &str = "g"; +/// Option name for quality. +pub(super) const QUALITY: &str = "quality"; +/// Shorthand for quality. +pub(super) const QUALITY_SHORT: &str = "q"; +/// Option name for format-specific quality. +pub(super) const FORMAT_QUALITY: &str = "format_quality"; +/// Shorthand for format_quality. +pub(super) const FORMAT_QUALITY_SHORT: &str = "fq"; +/// Option name for auto_rotate. +pub(super) const AUTO_ROTATE: &str = "auto_rotate"; +/// Shorthand for auto_rotate. +pub(super) const AUTO_ROTATE_SHORT: &str = "ar"; +/// Option name for background. +pub(super) const BACKGROUND: &str = "background"; +/// Shorthand for background. +pub(super) const BACKGROUND_SHORT: &str = "bg"; +/// Option name for enlarge. +pub(super) const ENLARGE: &str = "enlarge"; +/// Shorthand for enlarge. +pub(super) const ENLARGE_SHORT: &str = "el"; +/// Option name for extend. +pub(super) const EXTEND: &str = "extend"; +/// Shorthand for extend. +pub(super) const EXTEND_SHORT: &str = "ex"; +/// Option name for extend_aspect_ratio. +pub(super) const EXTEND_ASPECT_RATIO: &str = "extend_aspect_ratio"; +/// Alternate spelling for extend_aspect_ratio. +pub(super) const EXTEND_ASPECT_RATIO_ALT: &str = "extend_ar"; +/// Shorthand for extend_aspect_ratio. +pub(super) const EXTEND_ASPECT_RATIO_SHORT: &str = "exar"; +/// Option name for padding. +pub(super) const PADDING: &str = "padding"; +/// Shorthand for padding. +pub(super) const PADDING_SHORT: &str = "pd"; +/// Option name for rotation. +pub(super) const ROTATE: &str = "rotate"; +/// Shorthand for rotation. +pub(super) const ROTATE_SHORT: &str = "rot"; +/// Option name for flip. +pub(super) const FLIP: &str = "flip"; +/// Shorthand for flip. +pub(super) const FLIP_SHORT: &str = "fl"; +/// Option name for raw. +pub(super) const RAW: &str = "raw"; +/// Option name for blur. +pub(super) const BLUR: &str = "blur"; +/// Shorthand for blur. +pub(super) const BLUR_SHORT: &str = "bl"; +/// Option name for crop. +pub(super) const CROP: &str = "crop"; +/// Shorthand for crop. +pub(super) const CROP_SHORT: &str = "c"; +/// Option name for format. +pub(super) const FORMAT: &str = "format"; +/// Shorthand for format. +pub(super) const FORMAT_SHORT: &str = "f"; +/// Alternate shorthand for format. +pub(super) const FORMAT_EXT: &str = "ext"; +/// Option name for max_src_resolution. +pub(super) const MAX_SRC_RESOLUTION: &str = "max_src_resolution"; +/// Shorthand for max_src_resolution. +pub(super) const MAX_SRC_RESOLUTION_SHORT: &str = "msr"; +/// Option name for trim. +pub(super) const TRIM: &str = "trim"; +/// Shorthand for trim. +pub(super) const TRIM_SHORT: &str = "t"; +/// Option name for max_result_dimension. +pub(super) const MAX_RESULT_DIMENSION: &str = "max_result_dimension"; +/// Shorthand for max_result_dimension. +pub(super) const MAX_RESULT_DIMENSION_SHORT: &str = "mrd"; +/// Option name for max_src_file_size. +pub(super) const MAX_SRC_FILE_SIZE: &str = "max_src_file_size"; +/// Shorthand for max_src_file_size. +pub(super) const MAX_SRC_FILE_SIZE_SHORT: &str = "msfs"; +/// Option name for max_animation_frames. +pub(super) const MAX_ANIMATION_FRAMES: &str = "max_animation_frames"; +/// Shorthand for max_animation_frames. +pub(super) const MAX_ANIMATION_FRAMES_SHORT: &str = "maf"; +/// Option name for max_animation_frame_resolution. +pub(super) const MAX_ANIMATION_FRAME_RESOLUTION: &str = "max_animation_frame_resolution"; +/// Shorthand for max_animation_frame_resolution. +pub(super) const MAX_ANIMATION_FRAME_RESOLUTION_SHORT: &str = "mafr"; +/// Option name for cache buster. +pub(super) const CACHEBUSTER: &str = "cachebuster"; +/// Shorthand for cachebuster. +pub(super) const CACHEBUSTER_SHORT: &str = "cb"; +/// Option name for dpr. +pub(super) const DPR: &str = "dpr"; +/// Option name for min-width. +pub(super) const MIN_WIDTH: &str = "min-width"; +/// Alternate spelling for min-width. +pub(super) const MIN_WIDTH_ALT: &str = "min_width"; +/// Shorthand for min_width. +pub(super) const MIN_WIDTH_SHORT: &str = "mw"; +/// Option name for min-height. +pub(super) const MIN_HEIGHT: &str = "min-height"; +/// Alternate spelling for min-height. +pub(super) const MIN_HEIGHT_ALT: &str = "min_height"; +/// Shorthand for min_height. +pub(super) const MIN_HEIGHT_SHORT: &str = "mh"; +/// Option name for zoom. +pub(super) const ZOOM: &str = "zoom"; +/// Shorthand for zoom. +pub(super) const ZOOM_SHORT: &str = "z"; +/// Option name for sharpen. +pub(super) const SHARPEN: &str = "sharpen"; +/// Shorthand for sharpen. +pub(super) const SHARPEN_SHORT: &str = "sh"; +/// Option name for pixelate. +pub(super) const PIXELATE: &str = "pixelate"; +/// Shorthand for pixelate. +pub(super) const PIXELATE_SHORT: &str = "pix"; +/// Option name for watermark. +pub(super) const WATERMARK: &str = "watermark"; +/// Shorthand for watermark. +pub(super) const WATERMARK_SHORT: &str = "wm"; +/// Option name for watermark_url. +pub(super) const WATERMARK_URL: &str = "watermark_url"; +/// Shorthand for watermark_url. +pub(super) const WATERMARK_URL_SHORT: &str = "wmu"; +/// Option name for resizing_algorithm. +pub(super) const RESIZING_ALGORITHM: &str = "resizing_algorithm"; +/// Shorthand for resizing_algorithm. +pub(super) const RESIZING_ALGORITHM_SHORT: &str = "ra"; +/// Option name for background_alpha. +pub(super) const BACKGROUND_ALPHA: &str = "background_alpha"; +/// Shorthand for background_alpha. +pub(super) const BACKGROUND_ALPHA_SHORT: &str = "bga"; +/// Option name for adjust. +pub(super) const ADJUST: &str = "adjust"; +/// Shorthand for adjust. +pub(super) const ADJUST_SHORT: &str = "a"; +/// Option name for brightness. +pub(super) const BRIGHTNESS: &str = "brightness"; +/// Shorthand for brightness. +pub(super) const BRIGHTNESS_SHORT: &str = "br"; +/// Option name for contrast. +pub(super) const CONTRAST: &str = "contrast"; +/// Shorthand for contrast. +pub(super) const CONTRAST_SHORT: &str = "co"; +/// Option name for saturation. +pub(super) const SATURATION: &str = "saturation"; +/// Shorthand for saturation. +pub(super) const SATURATION_SHORT: &str = "sa"; +/// Option name for max_bytes. +pub(super) const MAX_BYTES: &str = "max_bytes"; +/// Shorthand for max_bytes. +pub(super) const MAX_BYTES_SHORT: &str = "mb"; +/// Option name for strip_metadata. +pub(super) const STRIP_METADATA: &str = "strip_metadata"; +/// Shorthand for strip_metadata. +pub(super) const STRIP_METADATA_SHORT: &str = "sm"; +/// Option name for keep_copyright. +pub(super) const KEEP_COPYRIGHT: &str = "keep_copyright"; +/// Shorthand for keep_copyright. +pub(super) const KEEP_COPYRIGHT_SHORT: &str = "kcr"; +/// Option name for strip_color_profile. +pub(super) const STRIP_COLOR_PROFILE: &str = "strip_color_profile"; +/// Shorthand for strip_color_profile. +pub(super) const STRIP_COLOR_PROFILE_SHORT: &str = "scp"; +/// Option name for enforce_thumbnail. +pub(super) const ENFORCE_THUMBNAIL: &str = "enforce_thumbnail"; +/// Shorthand for enforce_thumbnail. +pub(super) const ENFORCE_THUMBNAIL_SHORT: &str = "eth"; +/// Option name for preserve_hdr. +pub(super) const PRESERVE_HDR: &str = "preserve_hdr"; +/// Shorthand for preserve_hdr. +pub(super) const PRESERVE_HDR_SHORT: &str = "ph"; +/// Option name for JPEG options. +pub(super) const JPEG_OPTIONS: &str = "jpeg_options"; +/// Shorthand for JPEG options. +pub(super) const JPEG_OPTIONS_SHORT: &str = "jpgo"; +/// Option name for PNG options. +pub(super) const PNG_OPTIONS: &str = "png_options"; +/// Shorthand for PNG options. +pub(super) const PNG_OPTIONS_SHORT: &str = "pngo"; +/// Option name for WebP options. +pub(super) const WEBP_OPTIONS: &str = "webp_options"; +/// Shorthand for WebP options. +pub(super) const WEBP_OPTIONS_SHORT: &str = "webpo"; +/// Option name for AVIF options. +pub(super) const AVIF_OPTIONS: &str = "avif_options"; +/// Shorthand for AVIF options. +pub(super) const AVIF_OPTIONS_SHORT: &str = "avifo"; +/// Option name for page. +pub(super) const PAGE: &str = "page"; +/// Shorthand for page. +pub(super) const PAGE_SHORT: &str = "pg"; +/// Option name for pages. +pub(super) const PAGES: &str = "pages"; +/// Shorthand for pages. +pub(super) const PAGES_SHORT: &str = "pgs"; +/// Option name for disable_animation. +pub(super) const DISABLE_ANIMATION: &str = "disable_animation"; +/// Shorthand for disable_animation. +pub(super) const DISABLE_ANIMATION_SHORT: &str = "da"; +/// Option name for skip_processing. +pub(super) const SKIP_PROCESSING: &str = "skip_processing"; +/// Shorthand for skip_processing. +pub(super) const SKIP_PROCESSING_SHORT: &str = "skp"; +/// Option name for expires. +pub(super) const EXPIRES: &str = "expires"; +/// Shorthand for expires. +pub(super) const EXPIRES_SHORT: &str = "exp"; +/// Option name for filename. +pub(super) const FILENAME: &str = "filename"; +/// Shorthand for filename. +pub(super) const FILENAME_SHORT: &str = "fn"; +/// Option name for return_attachment. +pub(super) const RETURN_ATTACHMENT: &str = "return_attachment"; +/// Shorthand for return_attachment. +pub(super) const RETURN_ATTACHMENT_SHORT: &str = "att"; diff --git a/src/processing/pipeline.rs b/src/processing/pipeline.rs new file mode 100644 index 0000000..e6edd3b --- /dev/null +++ b/src/processing/pipeline.rs @@ -0,0 +1,189 @@ +//! The ordered sequence of transformations applied to a single frame. +//! +//! Animated sources run every frame through [`transform_frame`] separately, so +//! this stays a pure image-in/image-out step with no knowledge of how many +//! frames there are. + +use crate::processing::options::{ParsedOptions, ResizingType}; +use crate::processing::transform::{self, TransformError}; +use crate::processing::watermark::{self, CachedWatermark, WatermarkError}; +use libvips::VipsImage; +use thiserror::Error; +use tracing::debug; + +/// Errors produced while transforming a frame. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum PipelineError { + #[error(transparent)] + Transform(#[from] TransformError), + #[error(transparent)] + Watermark(#[from] WatermarkError), +} + +/// The dimensions the resize was asked for, kept so the padding steps that run +/// after it know what box the image was meant to fill. +#[derive(Debug, Clone, Copy, Default)] +pub struct FrameTargets { + pub resize: Option<(u32, u32)>, +} + +/// Applies every geometry and pixel operation the request asks for, in the +/// order imgproxy applies them. +pub fn transform_frame( + mut img: VipsImage, + options: &ParsedOptions, + watermark_source: Option<&CachedWatermark>, +) -> Result { + let mut targets = FrameTargets::default(); + + // Trim before anything that depends on the image's extent: the borders it + // removes would otherwise skew the crop window and the resize target. + if let Some(trim) = options.trim.as_ref() { + debug!("Applying trim: {:?}", trim); + img = transform::apply_trim(img, trim)?; + } + + if let Some(crop) = options.crop.as_ref() { + debug!("Applying crop: {:?}", crop); + img = transform::crop_image(img, crop, &options.crop_gravity())?; + } + + if let Some(resize) = options.resize.as_ref() { + let src_width = img.get_width().max(0) as u32; + let src_height = img.get_height().max(0) as u32; + let (target_w, target_h) = transform::resolve_resize_dimensions(resize, src_width, src_height)?; + debug!( + "Applying resize {:?} resolved to {}x{} from source {}x{}", + resize, target_w, target_h, src_width, src_height + ); + targets.resize = Some((target_w, target_h)); + + // The enlargement cap lives inside apply_resize, per resizing type. It + // used to be here, comparing the requested box against the source and + // skipping the whole resize when either side was larger — which threw + // away downscales that never enlarged anything. + img = transform::apply_resize( + img, + resize, + &options.fill_gravity(), + options.resizing_algorithm.as_deref(), + options.enlarge, + f64::from(options.dpr_factor()), + )?; + } + + if options.min_width.is_some() || options.min_height.is_some() { + debug!( + "Applying min dimensions: min_width={:?}, min_height={:?}", + options.min_width, options.min_height + ); + img = transform::apply_min_dimensions( + img, + options.min_width, + options.min_height, + options.resizing_algorithm.as_deref(), + )?; + } + + let zoom = options.zoom_factors(); + if !zoom.is_identity() { + debug!("Applying zoom: {:?}", zoom); + img = transform::apply_zoom(img, zoom, options.resizing_algorithm.as_deref())?; + } + + img = apply_extend(img, options, &targets)?; + + if let Some((top, right, bottom, left)) = options.padding { + debug!("Applying padding: {:?}", (top, right, bottom, left)); + img = transform::apply_padding(img, top, right, bottom, left, &options.background)?; + } + + if let Some(rotation) = options.rotation { + debug!("Applying rotation: {}", rotation); + img = transform::apply_rotation(img, rotation)?; + } + + if let Some(flip) = options.flip { + debug!("Applying flip: {:?}", flip); + img = transform::apply_flip(img, flip)?; + } + + if let Some(adjust) = options.adjust.filter(|adjust| !adjust.is_identity()) { + debug!("Applying color adjustments: {:?}", adjust); + img = transform::apply_adjust(img, adjust)?; + } + + if let Some(sigma) = options.blur { + debug!("Applying blur with sigma: {}", sigma); + img = transform::apply_blur(img, sigma)?; + } + + if let Some(sigma) = options.sharpen { + debug!("Applying sharpen with sigma: {}", sigma); + img = transform::apply_sharpen(img, sigma)?; + } + + if let Some(amount) = options.pixelate { + debug!("Applying pixelate with amount: {}", amount); + img = transform::apply_pixelate(img, amount)?; + } + + if let (Some(watermark_opts), Some(source)) = (options.watermark.as_ref(), watermark_source) { + debug!("Applying watermark with options: {:?}", watermark_opts); + img = watermark::apply_watermark(img, source, watermark_opts, options.resizing_algorithm.as_deref())?; + } + + Ok(img) +} + +/// Runs `extend` and then `extend_aspect_ratio`. +/// +/// `extend` pads out to the requested pixel box; `extend_aspect_ratio` pads out +/// to the requested *shape* without reaching that box. imgproxy runs both, in +/// this order, and a request may set either or both. +fn apply_extend( + mut img: VipsImage, + options: &ParsedOptions, + targets: &FrameTargets, +) -> Result { + let Some((target_w, target_h)) = targets.resize else { + return Ok(img); + }; + + // `force` already hit the box exactly, so there is nothing left to pad — + // and its target is the source's own size on any zero axis, which would + // make an aspect-ratio extend meaningless. + let forced = options + .resize + .as_ref() + .is_some_and(|resize| resize.resizing_type == ResizingType::Force); + + if options.extend.enabled { + debug!("Extending to {}x{}", target_w, target_h); + let gravity = options.extend.gravity.unwrap_or_default(); + img = transform::extend_image( + img, + target_w, + target_h, + &gravity, + &options.background, + f64::from(options.dpr_factor()), + )?; + } + + if options.extend_aspect_ratio.enabled && !forced { + debug!("Extending to aspect ratio {}:{}", target_w, target_h); + let gravity = options.extend_aspect_ratio.gravity.unwrap_or_default(); + img = transform::extend_to_aspect_ratio( + img, + target_w, + target_h, + &gravity, + &options.background, + f64::from(options.dpr_factor()), + )?; + } + + Ok(img) +} diff --git a/src/processing/save.rs b/src/processing/save.rs index 5f124e1..abe0b68 100644 --- a/src/processing/save.rs +++ b/src/processing/save.rs @@ -1,10 +1,26 @@ +//! Encoding a processed image. +//! +//! Every format goes through libvips' save-suffix parser rather than the +//! generated `*save_buffer_with_opts` bindings. Those bindings pass every +//! option as a varargs name/value pair, including properties that only exist in +//! libvips 8.16 and later — `exact` on webpsave, `tune` on heifsave, +//! `keep-duplicate-frames` on gifsave. An older libvips rejects the entire call +//! with "no property named ...", so nothing encodes at all; Ubuntu 24.04 ships +//! 8.15.1, which is exactly that case. +//! +//! The suffix sets only the options named here, so it stays correct across +//! libvips versions, and it is the only form that can express a combination of +//! metadata `keep` flags — `keep=exif|icc` has no counterpart in the generated +//! bindings' single-variant enum. + use crate::processing::options::SaveOptions; -use libvips::{bindings, ops, VipsImage}; +use libvips::{bindings, VipsImage}; use std::collections::HashSet; use std::ffi::CString; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::sync::OnceLock; use thiserror::Error; +use tracing::debug; /// Errors produced while encoding an image. #[derive(Debug, Error)] @@ -22,40 +38,179 @@ pub enum SaveError { }, } +/// Every format imgforge knows how to encode, with the capabilities that decide +/// what the pipeline may hand the encoder. +struct FormatSpec { + /// Canonical name, which is also the file suffix vips is given. + name: &'static str, + /// Whether the format can carry an alpha channel. + alpha: bool, + /// Whether the format can carry an embedded colour profile. + color_profile: bool, + /// Whether the format can carry more than one frame. + animation: bool, + /// Whether the format can carry more than 8 bits per channel. + high_bit_depth: bool, + /// Largest side the format's container can address, if it is bounded + /// tightly enough to matter in practice. + max_dimension: Option, +} + +const FORMATS: &[FormatSpec] = &[ + FormatSpec { + name: "jpeg", + alpha: false, + color_profile: true, + animation: false, + high_bit_depth: false, + max_dimension: Some(65_535), + }, + FormatSpec { + name: "png", + alpha: true, + color_profile: true, + animation: false, + high_bit_depth: true, + max_dimension: None, + }, + FormatSpec { + name: "webp", + alpha: true, + color_profile: true, + animation: true, + high_bit_depth: false, + // libwebp refuses anything larger; see its encode.h. + max_dimension: Some(16_383), + }, + FormatSpec { + name: "tiff", + alpha: true, + color_profile: true, + animation: false, + high_bit_depth: true, + max_dimension: None, + }, + FormatSpec { + name: "gif", + alpha: true, + color_profile: false, + animation: true, + high_bit_depth: false, + max_dimension: Some(65_535), + }, + FormatSpec { + name: "avif", + alpha: true, + color_profile: true, + animation: true, + high_bit_depth: true, + max_dimension: Some(16_384), + }, + FormatSpec { + name: "heif", + alpha: true, + color_profile: true, + animation: true, + high_bit_depth: true, + max_dimension: Some(16_384), + }, +]; + +/// Resolves a requested format name, including the aliases URLs use. +fn canonical_format(format: &str) -> Option<&'static FormatSpec> { + let name = match format.to_lowercase().as_str() { + "jpg" | "jpeg" => "jpeg", + "heic" | "heif" => "heif", + "tif" | "tiff" => "tiff", + other => return FORMATS.iter().find(|spec| spec.name == other), + }; + FORMATS.iter().find(|spec| spec.name == name) +} + +/// The canonical spelling of a requested format, or `None` if unrecognised. +/// +/// URLs may name a format by any of its aliases, and everything downstream keys +/// off the string that arrives: the response `Content-Type`, the +/// `format_quality` lookup, and the metrics label. Left un-normalised, +/// `format:tif` selected the TIFF encoder and then fell through +/// `format_to_content_type`'s catch-all, so the client received TIFF bytes +/// labelled `image/jpeg` — and its metrics landed under a second, separate +/// format name. +pub fn canonical_format_name(format: &str) -> Option<&'static str> { + canonical_format(format).map(|spec| spec.name) +} + +/// Whether the format can carry an alpha channel. +pub fn format_supports_alpha(format: &str) -> bool { + canonical_format(format).is_some_and(|spec| spec.alpha) +} + +/// Whether the format can carry an embedded colour profile. +pub fn format_supports_color_profile(format: &str) -> bool { + canonical_format(format).is_some_and(|spec| spec.color_profile) +} + +/// Whether the format can carry more than one frame. +pub fn format_supports_animation(format: &str) -> bool { + canonical_format(format).is_some_and(|spec| spec.animation) +} + +/// Whether the format can carry more than 8 bits per channel. +pub fn format_supports_high_bit_depth(format: &str) -> bool { + canonical_format(format).is_some_and(|spec| spec.high_bit_depth) +} + +/// Largest side the format's container can address. +pub fn format_max_dimension(format: &str) -> Option { + canonical_format(format).and_then(|spec| spec.max_dimension) +} + /// Saves an image to bytes in the specified format. pub fn save_image(img: VipsImage, format: &str, quality: u8) -> Result, SaveError> { - save_image_with_options(img, format, quality, &SaveOptions::default()) + save_image_with_options(img, format, quality, &SaveOptions::default(), None) } /// Saves an image to bytes using imgproxy-compatible encoder controls. +/// +/// `page_height` carries the frame height of an animation. libvips stores an +/// animation as one tall image and needs telling where the frames divide; +/// without it the encoder writes a single very tall still. pub fn save_image_with_options( img: VipsImage, format: &str, quality: u8, options: &SaveOptions, + page_height: Option, ) -> Result, SaveError> { - let format = format.to_lowercase(); + let Some(spec) = canonical_format(format) else { + return Err(SaveError::UnsupportedFormat { + format: format.to_string(), + }); + }; - if !is_format_supported(&format) { - return Err(SaveError::UnsupportedFormat { format }); + if !is_format_supported(spec.name) { + return Err(SaveError::UnsupportedFormat { + format: format.to_string(), + }); } - encode_with_max_bytes(&img, &format, quality, options) + encode_with_max_bytes(&img, spec, quality, options, page_height) } fn encode_with_max_bytes( img: &VipsImage, - format: &str, + spec: &FormatSpec, quality: u8, options: &SaveOptions, + page_height: Option, ) -> Result, SaveError> { let Some(max_bytes) = options.max_bytes else { - return encode_once(img, format, quality, options); + return encode_once(img, spec, quality, options, page_height); }; let mut quality = quality.clamp(1, 100); loop { - let bytes = encode_once(img, format, quality, options)?; + let bytes = encode_once(img, spec, quality, options, page_height)?; if bytes.len() <= max_bytes || quality <= 1 { return Ok(bytes); } @@ -63,44 +218,81 @@ fn encode_with_max_bytes( } } -fn metadata_keep(options: &SaveOptions) -> ops::ForeignKeep { - if options.strip_metadata.unwrap_or(false) || options.strip_color_profile.unwrap_or(false) { - ops::ForeignKeep::None +/// The `keep` flag combination for a request. +/// +/// libvips' flags are `none|exif|xmp|iptc|icc|other|gainmap|all`, and the two +/// strip options address different subsets of them: `strip_metadata` drops the +/// descriptive tags, `strip_color_profile` drops the ICC profile. Treating +/// either as "drop everything" — which is what a single-variant enum forces — +/// meant asking to drop the colour profile also silently discarded the EXIF. +fn metadata_keep(options: &SaveOptions) -> String { + let strip_metadata = options.strip_metadata.unwrap_or(false); + let strip_profile = options.strip_color_profile.unwrap_or(false); + + if !strip_metadata && !strip_profile { + return "all".to_string(); + } + + let mut flags: Vec<&str> = Vec::new(); + if !strip_metadata { + flags.extend_from_slice(&["exif", "xmp", "iptc", "other"]); + } + if !strip_profile { + flags.push("icc"); + } + // A gain map is what makes an HDR image high dynamic range; it is neither + // descriptive metadata nor a colour profile, so neither strip option should + // take it away when the request explicitly asked to preserve it. + if options.preserve_hdr.unwrap_or(false) { + flags.push("gainmap"); + } + + if flags.is_empty() { + "none".to_string() } else { - ops::ForeignKeep::All + flags.join("|") } } -// WebP, AVIF/HEIF, and GIF encode through the save suffix rather than the -// generated `*save_buffer_with_opts` bindings. Those bindings pass every -// option as a varargs name/value pair, including properties that only exist -// in libvips 8.16 and later: `exact` on webpsave, `tune` on heifsave, -// `keep-duplicate-frames` on gifsave. An older libvips rejects the entire -// call with "no property named ...", so nothing encodes at all. Ubuntu 24.04 -// — the base of the published image — ships libvips 8.15.1, which is exactly -// that case; a WebP save there was also reported to abort the process. -// -// The suffix goes through vips' option-string parser, which sets only the -// options named here, so it stays correct across libvips versions. JPEG, PNG, -// and TIFF keep the generated bindings: every property they pass predates 8.15. - -/// Builds the WebP save suffix carrying the encoder options. -pub(crate) fn webp_save_suffix(quality: u8, keep: ops::ForeignKeep, options: &SaveOptions) -> String { - let mut suffix = format!(".webp[Q={}", (quality as i32).clamp(1, 100)); - if options.webp.lossless.unwrap_or(false) { - suffix.push_str(",lossless"); +/// Builds a libvips save suffix: `.png[option,option=value]`. +struct Suffix { + parts: Vec, + extension: &'static str, +} + +impl Suffix { + fn new(extension: &'static str) -> Self { + Self { + parts: Vec::new(), + extension, + } + } + + fn flag(mut self, name: &str, enabled: bool) -> Self { + if enabled { + self.parts.push(name.to_string()); + } + self + } + + fn value(mut self, name: &str, value: impl std::fmt::Display) -> Self { + self.parts.push(format!("{name}={value}")); + self } - if options.webp.smart_subsample.unwrap_or(false) { - suffix.push_str(",smart-subsample"); + + fn maybe(self, name: &str, value: Option) -> Self { + match value { + Some(value) => self.value(name, value), + None => self, + } } - if let Some(preset) = options.webp.preset.as_deref().and_then(webp_preset_nickname) { - suffix.push_str(",preset="); - suffix.push_str(preset); + + fn build(self) -> String { + if self.parts.is_empty() { + return format!(".{}", self.extension); + } + format!(".{}[{}]", self.extension, self.parts.join(",")) } - suffix.push_str(",keep="); - suffix.push_str(foreign_keep_nickname(keep)); - suffix.push(']'); - suffix } /// Maps a requested WebP preset to the matching vips nickname. @@ -120,170 +312,195 @@ fn webp_preset_nickname(preset: &str) -> Option<&'static str> { } } -/// Builds the AVIF/HEIF save suffix carrying the encoder options. -pub(crate) fn heif_save_suffix( - extension: &str, - compression: &str, +/// Builds the encoder suffix for one format. +pub(crate) fn save_suffix( + format: &str, quality: u8, - effort: i32, - keep: ops::ForeignKeep, options: &SaveOptions, -) -> String { - format!( - ".{}[Q={},compression={},effort={},subsample-mode={},keep={}]", - extension, - (quality as i32).clamp(1, 100), - compression, - effort.clamp(0, 9), - if options.avif.no_subsample.unwrap_or(false) { - "off" - } else { - "auto" - }, - foreign_keep_nickname(keep), - ) -} - -/// Builds the GIF save suffix. -pub(crate) fn gif_save_suffix(effort: i32, keep: ops::ForeignKeep) -> String { - format!( - ".gif[effort={},keep={}]", - effort.clamp(1, 10), - foreign_keep_nickname(keep) - ) -} - -/// Matched exhaustively so a new `ForeignKeep` variant breaks the build here -/// rather than silently dropping the metadata setting from the suffix. -fn foreign_keep_nickname(keep: ops::ForeignKeep) -> &'static str { - match keep { - ops::ForeignKeep::None => "none", - ops::ForeignKeep::Exif => "exif", - ops::ForeignKeep::Xmp => "xmp", - ops::ForeignKeep::Iptc => "iptc", - ops::ForeignKeep::Icc => "icc", - ops::ForeignKeep::Other => "other", - ops::ForeignKeep::Gainmap => "gainmap", - ops::ForeignKeep::All => "all", - } -} + page_height: Option, +) -> Result { + let Some(spec) = canonical_format(format) else { + return Err(SaveError::UnsupportedFormat { + format: format.to_string(), + }); + }; -fn encode_once(img: &VipsImage, format: &str, quality: u8, options: &SaveOptions) -> Result, SaveError> { - // map quality to effort (1-10), higher quality = more effort - let effort = ((quality as i32).clamp(1, 100) / 10).clamp(1, 10); + let quality = i32::from(quality).clamp(1, 100); + // Map quality onto the effort scales the slower encoders take, so a request + // for high quality also buys the extra search those formats offer. + let effort = (quality / 10).clamp(1, 10); let keep = metadata_keep(options); + let page_height = page_height.filter(|height| *height > 0 && spec.animation); - match format { - "jpeg" | "jpg" => encode_image("JPEG", || { - let opts = ops::JpegsaveBufferOptions { - q: quality as i32, - optimize_coding: true, - interlace: options.save_jpeg_progressive(), - trellis_quant: options.jpeg.trellis_quant.unwrap_or(false), - overshoot_deringing: options.jpeg.overshoot_deringing.unwrap_or(false), - optimize_scans: options.jpeg.optimize_scans.unwrap_or(false), - quant_table: options.jpeg.quant_table.unwrap_or(0).clamp(0, 8), - subsample_mode: if options.jpeg.no_subsample.unwrap_or(false) { - ops::ForeignSubsample::Off + let suffix = match spec.name { + "jpeg" => Suffix::new("jpg") + .value("Q", quality) + .flag("optimize-coding", true) + .flag("interlace", options.jpeg.progressive.unwrap_or(false)) + .flag("trellis-quant", options.jpeg.trellis_quant.unwrap_or(false)) + .flag("overshoot-deringing", options.jpeg.overshoot_deringing.unwrap_or(false)) + .flag("optimize-scans", options.jpeg.optimize_scans.unwrap_or(false)) + .value("quant-table", options.jpeg.quant_table.unwrap_or(0).clamp(0, 8)) + .value( + "subsample-mode", + if options.jpeg.no_subsample.unwrap_or(false) { + "off" } else { - ops::ForeignSubsample::Auto + "auto" }, - keep, - ..Default::default() - }; - ops::jpegsave_buffer_with_opts(img, &opts) - }), - "png" => encode_image("PNG", || { - let opts = ops::PngsaveBufferOptions { - interlace: options.png.interlaced.unwrap_or(false), - palette: options.png.quantize.unwrap_or(false), - q: options - .png - .quantization_colors - .map(|colors| colors.min(256) as i32) - .unwrap_or(100), - effort, - keep, - ..Default::default() - }; - ops::pngsave_buffer_with_opts(img, &opts) - }), - "webp" => encode_image("WebP", || { - img.image_write_to_buffer(&webp_save_suffix(quality, keep, options)) - }), - "tiff" => encode_image("TIFF", || { - let clamped_quality = (quality as i32).clamp(1, 100); - let compression = if clamped_quality == 100 { + ) + .value("keep", keep), + "png" => { + let palette = options.png.quantize.unwrap_or(false); + Suffix::new("png") + .flag("interlace", options.png.interlaced.unwrap_or(false)) + .flag("palette", palette) + .value( + "Q", + options + .png + .quantization_colors + .map(|colors| i32::from(colors.min(256))) + .unwrap_or(100), + ) + .value("effort", effort) + .value("keep", keep) + } + "webp" => Suffix::new("webp") + .value("Q", quality) + .flag("lossless", options.webp.lossless.unwrap_or(false)) + .flag("smart-subsample", options.webp.smart_subsample.unwrap_or(false)) + .maybe("preset", options.webp.preset.as_deref().and_then(webp_preset_nickname)) + .maybe("page-height", page_height) + .value("keep", keep), + "tiff" => Suffix::new("tif") + .value("Q", quality) + .value( + "compression", // Preserve lossless output when callers request max quality. - ops::ForeignTiffCompression::Lzw + if quality == 100 { "lzw" } else { "jpeg" }, + ) + .value("keep", keep), + "gif" => Suffix::new("gif") + .value("effort", effort.clamp(1, 10)) + .maybe("page-height", page_height) + .value("keep", keep), + "avif" | "heif" => { + let (extension, compression) = if spec.name == "avif" { + ("avif", "av1") } else { - ops::ForeignTiffCompression::Jpeg - }; - - let opts = ops::TiffsaveBufferOptions { - q: clamped_quality, - compression, - keep, - ..Default::default() + ("heif", "hevc") }; + Suffix::new(extension) + .value("Q", quality) + .value("compression", compression) + .value("effort", (effort - 1).clamp(0, 9)) + .value( + "subsample-mode", + if options.avif.no_subsample.unwrap_or(false) { + "off" + } else { + "auto" + }, + ) + .maybe("page-height", page_height) + .value("keep", keep) + } + other => { + return Err(SaveError::UnsupportedFormat { + format: other.to_string(), + }) + } + }; - ops::tiffsave_buffer_with_opts(img, &opts) - }), - "gif" => encode_image("GIF", || img.image_write_to_buffer(&gif_save_suffix(effort, keep))), - "avif" => encode_image("AVIF", || { - img.image_write_to_buffer(&heif_save_suffix("avif", "av1", quality, effort - 1, keep, options)) - }), - "heif" | "heic" => encode_image("HEIF", || { - img.image_write_to_buffer(&heif_save_suffix("heif", "hevc", quality, effort - 1, keep, options)) - }), - _ => Err(SaveError::UnsupportedFormat { - format: format.to_string(), - }), - } + Ok(suffix.build()) } -trait SaveOptionExt { - fn save_jpeg_progressive(&self) -> bool; -} +fn encode_once( + img: &VipsImage, + spec: &FormatSpec, + quality: u8, + options: &SaveOptions, + page_height: Option, +) -> Result, SaveError> { + let suffix = save_suffix(spec.name, quality, options, page_height)?; + let label = spec.name; -impl SaveOptionExt for SaveOptions { - fn save_jpeg_progressive(&self) -> bool { - self.jpeg.progressive.unwrap_or(false) - } + catch_unwind(AssertUnwindSafe(|| img.image_write_to_buffer(&suffix))) + .map_err(|_| SaveError::EncoderPanicked { + format: static_label(label), + })? + .map_err(|source| SaveError::Vips { + format: static_label(label), + source, + }) } -fn encode_image(label: &'static str, op: F) -> Result, SaveError> -where - F: FnOnce() -> libvips::Result>, -{ - catch_unwind(AssertUnwindSafe(op)) - .map_err(|_| SaveError::EncoderPanicked { format: label })? - .map_err(|source| SaveError::Vips { format: label, source }) +/// Format names are compile-time constants; this hands the error type the +/// `'static` one matching a runtime name. +fn static_label(name: &str) -> &'static str { + FORMATS + .iter() + .find(|spec| spec.name == name) + .map(|spec| spec.name) + .unwrap_or("image") } pub(crate) fn is_format_supported(format: &str) -> bool { - let lower = format.to_lowercase(); - let supported = supported_formats(); - if supported.contains(&lower) { - return true; - } - - probe_format(&lower) + let Some(spec) = canonical_format(format) else { + return false; + }; + supported_formats().contains(spec.name) } -fn supported_formats() -> &'static HashSet { - static SUPPORTED: OnceLock> = OnceLock::new(); +fn supported_formats() -> &'static HashSet<&'static str> { + static SUPPORTED: OnceLock> = OnceLock::new(); SUPPORTED.get_or_init(|| { - // Probe the formats we know how to encode; this happens once at startup. - ["jpeg", "jpg", "png", "webp", "tiff", "gif", "avif", "heif"] + let supported: HashSet<&'static str> = FORMATS .iter() - .filter(|fmt| probe_format(fmt)) - .map(|fmt| fmt.to_string()) - .collect() + .filter(|spec| encoder_available(spec.name)) + .map(|spec| spec.name) + .collect(); + debug!("libvips can encode: {:?}", supported); + supported }) } -fn probe_format(format: &str) -> bool { +/// Whether this libvips build can actually produce the format. +/// +/// `vips_foreign_find_save` only answers whether a *saver* is registered, which +/// for HEIF is true on a build with no HEVC encoder behind it — the request then +/// failed at encode time with "Unsupported compression" and a 500, long after +/// the point where "this format is unavailable" could have been reported. The +/// codec-backed formats are therefore probed by encoding a real pixel once, at +/// startup, and the answer is cached for the process's lifetime. +fn encoder_available(format: &str) -> bool { + if !saver_registered(format) { + return false; + } + + if !matches!(format, "avif" | "heif") { + return true; + } + + let Ok(probe) = libvips::ops::black(2, 2) else { + return false; + }; + let Ok(suffix) = save_suffix(format, 50, &SaveOptions::default(), None) else { + return false; + }; + + match catch_unwind(AssertUnwindSafe(|| probe.image_write_to_buffer(&suffix))) { + Ok(Ok(bytes)) => !bytes.is_empty(), + Ok(Err(err)) => { + debug!("{} saver is registered but cannot encode: {}", format, err); + false + } + Err(_) => false, + } +} + +fn saver_registered(format: &str) -> bool { let candidates = [format.to_string(), format!(".{}", format), format!("output.{}", format)]; for candidate in candidates { diff --git a/src/processing/scale_on_load.rs b/src/processing/scale_on_load.rs new file mode 100644 index 0000000..8c168c1 --- /dev/null +++ b/src/processing/scale_on_load.rs @@ -0,0 +1,124 @@ +//! Choosing a reduced decode scale. +//! +//! A loader that can decode at a fraction of full size skips the work rather +//! than doing it and throwing the result away, which is the difference between +//! unpacking a 9000x7000 source and unpacking what a 450px result needs. + +use crate::processing::options::ParsedOptions; + +/// The JPEG loader can decode at 1/2, 1/4 or 1/8 scale, skipping the work +/// rather than doing it and throwing the result away. +const MAX_LOAD_SHRINK: u32 = 8; + +/// Below this, re-decoding at a reduced scale is not worth the divergence: at +/// 1.5 the pixel count already drops to 44%, and under it the saving thins out +/// fast. +const MIN_LOAD_SHRINK: f64 = 1.5; + +/// How much larger the source is than what the request needs, as a ratio. +/// +/// `None` means decode it whole: `raw` returns the source untouched, and a crop +/// addresses source pixels by coordinate, so shrinking underneath it would move +/// the region being cut. +fn load_shrink_ratio(parsed_options: &ParsedOptions, src_width: u32, src_height: u32) -> Option { + if parsed_options.raw { + return None; + } + // Trim removes an unknown number of pixels, so there is no way to tell how + // many will be left for the resize. Choosing a decode scale against that is + // guesswork, and guessing low leaves the resize short. imgproxy stands + // aside here too. + if parsed_options.trim.is_some() { + return None; + } + let resize = parsed_options.resize.as_ref()?; + if src_width == 0 || src_height == 0 { + return None; + } + + // A crop runs before the resize, so the pixels that have to survive are the + // crop region, not the whole source. Measuring against the source would + // shrink past what the crop still needs: an 8000x6000 source cropped to + // 2000x1500 and resized to 500 wide can only lose a factor of 4, not 16. + let (available_width, available_height) = match parsed_options.crop.as_ref() { + Some(crop) => { + let (crop_width, crop_height) = crop.resolve(src_width, src_height); + ( + if crop_width == 0 { + src_width + } else { + crop_width.min(src_width) + }, + if crop_height == 0 { + src_height + } else { + crop_height.min(src_height) + }, + ) + } + None => (src_width, src_height), + }; + + // Anything that can grow the target after this point has to be folded in, + // or the shrink could drop the source below what the pipeline still needs. + let grow = f64::from(parsed_options.dpr_factor()) * f64::from(parsed_options.zoom_factors().max_factor()); + + // `force` fills a zero axis from the *source* dimension, so that axis needs + // the source at full size. Every other type derives a zero axis from the + // aspect ratio, which survives a shrink unchanged. + let forced = resize.resizing_type.fills_zero_axis_from_source(); + let target_width = if forced && resize.width == 0 { + f64::from(available_width) + } else { + (f64::from(resize.width) * grow).max(f64::from(parsed_options.min_width.unwrap_or(0))) + }; + let target_height = if forced && resize.height == 0 { + f64::from(available_height) + } else { + (f64::from(resize.height) * grow).max(f64::from(parsed_options.min_height.unwrap_or(0))) + }; + + // The *least* shrink any axis needs, so the decoded image is still at least + // as large as the target on both. Overshooting would hand the pipeline a + // source smaller than the request, which `enlarge:false` then refuses to + // scale back up. + let mut ratio = f64::INFINITY; + if target_width >= 1.0 { + ratio = ratio.min(f64::from(available_width) / target_width); + } + if target_height >= 1.0 { + ratio = ratio.min(f64::from(available_height) / target_height); + } + (ratio.is_finite() && ratio >= MIN_LOAD_SHRINK).then_some(ratio) +} + +/// Power-of-two shrink for the JPEG loader, or 1 to decode at full size. +pub fn load_shrink_factor(parsed_options: &ParsedOptions, src_width: u32, src_height: u32) -> u32 { + let Some(ratio) = load_shrink_ratio(parsed_options, src_width, src_height) else { + return 1; + }; + + let mut factor = 1; + while factor * 2 <= MAX_LOAD_SHRINK && f64::from(factor * 2) <= ratio { + factor *= 2; + } + factor +} + +/// Continuous scale for the WebP loader, or `None` to decode at full size. +/// +/// WebP takes a scale rather than JPEG's power-of-two shrink, so it can decode +/// much closer to what is needed — a request needing a 3x reduction gets one, +/// where the JPEG path has to settle for 2x. +/// +/// The loader rounds decoded dimensions to nearest and can round down — 4000 x +/// 0.3333 is 1333.2 and decodes to 1333 — so an undershoot would be possible +/// with a scale that had been truncated on its way in. Deriving it exactly from +/// the target avoids that: the multiplication lands back on the target and the +/// rounding has nothing to shave. Checked over several million source/target +/// pairs, and guarded by a test that decodes real WebP data rather than +/// modelling the rounding. +pub fn load_scale_factor(parsed_options: &ParsedOptions, src_width: u32, src_height: u32) -> Option { + let scale = 1.0 / load_shrink_ratio(parsed_options, src_width, src_height)?; + (scale > 0.0 && scale < 1.0).then_some(scale) +} diff --git a/src/processing/tests.rs b/src/processing/tests.rs index c387aa0..e31bd65 100644 --- a/src/processing/tests.rs +++ b/src/processing/tests.rs @@ -33,3 +33,7 @@ mod pipeline_tests; #[cfg(test)] #[path = "tests/save_tests.rs"] mod save_tests; + +#[cfg(test)] +#[path = "tests/animation_limit_tests.rs"] +mod animation_limit_tests; diff --git a/src/processing/tests/animation_limit_tests.rs b/src/processing/tests/animation_limit_tests.rs new file mode 100644 index 0000000..94431fb --- /dev/null +++ b/src/processing/tests/animation_limit_tests.rs @@ -0,0 +1,94 @@ +//! `max_animation_frame_resolution` against sources that collapse to one frame. + +use crate::processing::options::ParsedOptions; +use crate::processing::{process_image, ProcessingError}; +use bytes::Bytes; +use image::codecs::gif::GifEncoder; +use image::{Delay, Frame, RgbaImage}; +use libvips::VipsImage; + +use super::tests_support::*; + +/// A two-frame GIF of `size` x `size`, so each frame is `size * size` pixels. +fn animated_gif(size: u32, frames: u32) -> Vec { + let mut bytes = Vec::new(); + { + let mut encoder = GifEncoder::new(&mut bytes); + for index in 0..frames { + // Frames that are identical get collapsed by some encoders, so each + // one is given its own colour. + let shade = (index * 60 % 256) as u8; + let image = RgbaImage::from_pixel(size, size, image::Rgba([shade, 20, 200, 255])); + encoder + .encode_frame(Frame::from_parts(image, 0, 0, Delay::from_numer_denom_ms(100, 1))) + .expect("frame should encode"); + } + } + bytes +} + +fn open(bytes: &Bytes, load_options: &str) -> VipsImage { + VipsImage::new_from_buffer(bytes, load_options).expect("the GIF should decode") +} + +/// The limit describes an animation frame, and a frame does not stop being one +/// because the request asked for only the first of them. Reading the frame +/// count off the frames in hand let `disable_animation` — and equally `pages:1` +/// or a still output format — walk an oversized animation straight past the +/// ceiling that exists to refuse it. +#[test] +fn a_collapsed_animation_still_meets_the_frame_limit() { + init_vips(); + + let bytes = Bytes::from(animated_gif(100, 3)); + let limit = "0.005".parse().expect("5000 pixels is a valid limit"); + + // Whole: three 10000-pixel frames against a 5000-pixel ceiling. + let options = ParsedOptions { + max_animation_frame_resolution: Some(limit), + format: Some("gif".to_string()), + ..ParsedOptions::default() + }; + let whole = process_image(open(&bytes, "n=-1"), options, &bytes, None); + assert!( + matches!(whole, Err(ProcessingError::FrameTooLarge { .. })), + "an oversized animation must be refused, got {:?}", + whole.map(|bytes| bytes.len()) + ); + + // Collapsed: the same source, opened as its first page alone. The frame is + // the same size, so the same answer is the only consistent one. + let options = ParsedOptions { + max_animation_frame_resolution: Some(limit), + disable_animation: true, + format: Some("png".to_string()), + ..ParsedOptions::default() + }; + let collapsed = process_image(open(&bytes, "page=0,n=1"), options, &bytes, None); + assert!( + matches!(collapsed, Err(ProcessingError::FrameTooLarge { .. })), + "disable_animation must not be a way past the frame limit, got {:?}", + collapsed.map(|bytes| bytes.len()) + ); +} + +/// The limit is about animation frames, so a genuine still image is measured by +/// `max_src_resolution` and must pass untouched. +#[test] +fn a_still_image_is_not_measured_against_the_frame_limit() { + init_vips(); + + let bytes = Bytes::from(create_test_image(100, 100)); + let options = ParsedOptions { + max_animation_frame_resolution: Some("0.005".parse().unwrap()), + format: Some("png".to_string()), + ..ParsedOptions::default() + }; + + let result = process_image(open(&bytes, ""), options, &bytes, None); + assert!( + result.is_ok(), + "a still image must not be refused by the animation limit: {:?}", + result.err() + ); +} diff --git a/src/processing/tests/effects_tests.rs b/src/processing/tests/effects_tests.rs index d698211..ab5929d 100644 --- a/src/processing/tests/effects_tests.rs +++ b/src/processing/tests/effects_tests.rs @@ -1,4 +1,5 @@ -use crate::processing::options::{Adjust, Crop, Flip, Gravity, Trim}; +use crate::processing::colorspace; +use crate::processing::options::{Adjust, Crop, Flip, Gravity, GravityType, Trim, Zoom}; use crate::processing::transform::{self, TransformError}; use libvips::{ops, VipsImage}; @@ -9,13 +10,11 @@ fn test_crop_image() { init_vips(); let img = image_from(create_test_image(400, 300)); let crop = Crop { - x: 10, - y: 20, - width: 100, - height: 150, + width: 100.0, + height: 150.0, gravity: None, }; - let cropped_img = transform::crop_image(img, crop).unwrap(); + let cropped_img = transform::crop_image(img, &crop, &Gravity::default()).unwrap(); assert_eq!(cropped_img.get_width(), 100); assert_eq!(cropped_img.get_height(), 150); } @@ -100,7 +99,7 @@ fn test_apply_min_dimensions() { fn test_apply_zoom() { init_vips(); let img = image_from(create_test_image(100, 100)); - let zoomed_img = transform::apply_zoom(img, 2.0, None).unwrap(); + let zoomed_img = transform::apply_zoom(img, Zoom { x: 2.0, y: 2.0 }, None).unwrap(); assert_eq!(zoomed_img.get_width(), 200); assert_eq!(zoomed_img.get_height(), 200); } @@ -118,7 +117,7 @@ fn test_apply_sharpen() { fn test_apply_pixelate() { init_vips(); let img = image_from(create_test_image(100, 100)); - let pixelated_img = transform::apply_pixelate(img, 10, None).unwrap(); + let pixelated_img = transform::apply_pixelate(img, 10).unwrap(); assert_eq!(pixelated_img.get_width(), 100); assert_eq!(pixelated_img.get_height(), 100); } @@ -127,7 +126,7 @@ fn test_apply_pixelate() { fn test_apply_pixelate_ignores_requested_resizing_kernel() { init_vips(); let img = image_from(create_quadrant_test_image(40, 40)); - let pixelated_img = transform::apply_pixelate(img, 10, Some("lanczos3")).unwrap(); + let pixelated_img = transform::apply_pixelate(img, 10).unwrap(); assert_eq!(pixelated_img.get_width(), 40); assert_eq!(pixelated_img.get_height(), 40); } @@ -136,7 +135,7 @@ fn test_apply_pixelate_ignores_requested_resizing_kernel() { fn test_apply_pixelate_with_extreme_amount_keeps_dimensions() { init_vips(); let img = image_from(create_test_image(10, 10)); - let pixelated_img = transform::apply_pixelate(img, 1_000, None).unwrap(); + let pixelated_img = transform::apply_pixelate(img, 1_000).unwrap(); assert_eq!(pixelated_img.get_width(), 10); assert_eq!(pixelated_img.get_height(), 10); } @@ -146,13 +145,11 @@ fn test_crop_at_edge() { init_vips(); let img = image_from(create_test_image(100, 100)); let crop = Crop { - x: 0, - y: 0, - width: 50, - height: 50, + width: 50.0, + height: 50.0, gravity: None, }; - let cropped_img = transform::crop_image(img, crop).unwrap(); + let cropped_img = transform::crop_image(img, &crop, &crop.gravity.unwrap_or_default()).unwrap(); assert_eq!(cropped_img.get_width(), 50); assert_eq!(cropped_img.get_height(), 50); } @@ -162,13 +159,11 @@ fn test_crop_bottom_right_corner() { init_vips(); let img = image_from(create_test_image(100, 100)); let crop = Crop { - x: 50, - y: 50, - width: 50, - height: 50, + width: 50.0, + height: 50.0, gravity: None, }; - let cropped_img = transform::crop_image(img, crop).unwrap(); + let cropped_img = transform::crop_image(img, &crop, &crop.gravity.unwrap_or_default()).unwrap(); assert_eq!(cropped_img.get_width(), 50); assert_eq!(cropped_img.get_height(), 50); } @@ -218,7 +213,7 @@ fn test_pixelate_zero() { init_vips(); let img = image_from(create_test_image(100, 100)); let original_width = img.get_width(); - let pixelated_img = transform::apply_pixelate(img, 0, None).unwrap(); + let pixelated_img = transform::apply_pixelate(img, 0).unwrap(); assert_eq!(pixelated_img.get_width(), original_width); } @@ -226,7 +221,7 @@ fn test_pixelate_zero() { fn test_pixelate_small_amount() { init_vips(); let img = image_from(create_test_image(100, 100)); - let pixelated_img = transform::apply_pixelate(img, 1, None).unwrap(); + let pixelated_img = transform::apply_pixelate(img, 1).unwrap(); assert_eq!(pixelated_img.get_width(), 100); } @@ -234,7 +229,7 @@ fn test_pixelate_small_amount() { fn test_pixelate_large_amount() { init_vips(); let img = image_from(create_test_image(200, 200)); - let pixelated_img = transform::apply_pixelate(img, 50, None).unwrap(); + let pixelated_img = transform::apply_pixelate(img, 50).unwrap(); assert_eq!(pixelated_img.get_width(), 200); assert_eq!(pixelated_img.get_height(), 200); } @@ -271,7 +266,7 @@ fn test_apply_min_dimensions_already_larger() { fn test_apply_zoom_scale_down() { init_vips(); let img = image_from(create_test_image(200, 200)); - let zoomed = transform::apply_zoom(img, 0.5, None).unwrap(); + let zoomed = transform::apply_zoom(img, Zoom { x: 0.5, y: 0.5 }, None).unwrap(); assert_eq!(zoomed.get_width(), 100); assert_eq!(zoomed.get_height(), 100); } @@ -280,7 +275,7 @@ fn test_apply_zoom_scale_down() { fn test_apply_zoom_scale_up() { init_vips(); let img = image_from(create_test_image(100, 100)); - let zoomed = transform::apply_zoom(img, 3.0, None).unwrap(); + let zoomed = transform::apply_zoom(img, Zoom { x: 3.0, y: 3.0 }, None).unwrap(); assert_eq!(zoomed.get_width(), 300); assert_eq!(zoomed.get_height(), 300); } @@ -290,7 +285,7 @@ fn test_apply_zoom_rejects_non_positive_values() { init_vips(); let img = image_from(create_test_image(100, 100)); assert!(matches!( - transform::apply_zoom(img, 0.0, None), + transform::apply_zoom(img, Zoom { x: 0.0, y: 0.0 }, None), Err(TransformError::InvalidArgument { operation: "zoom", .. }) )); } @@ -385,24 +380,23 @@ fn test_crop_window_is_positioned_by_gravity() { let source = create_quadrant_test_image(100, 100); let cases = [ - (None, [255, 0, 0, 255]), // no gravity -> top-left - (Some(Gravity::NorthWest), [255, 0, 0, 255]), - (Some(Gravity::NorthEast), [0, 255, 0, 255]), - (Some(Gravity::SouthWest), [0, 0, 255, 255]), - (Some(Gravity::SouthEast), [255, 255, 0, 255]), + (GravityType::NorthWest, [255, 0, 0, 255]), + (GravityType::NorthEast, [0, 255, 0, 255]), + (GravityType::SouthWest, [0, 0, 255, 255]), + (GravityType::SouthEast, [255, 255, 0, 255]), ]; - for (gravity, expected) in cases { + for (kind, expected) in cases { let img = VipsImage::new_from_buffer(&source, "").unwrap(); + let gravity = Gravity::new(kind); let cropped = transform::crop_image( img, - Crop { - x: 0, - y: 0, - width: 40, - height: 40, - gravity, + &Crop { + width: 40.0, + height: 40.0, + gravity: Some(gravity), }, + &gravity, ) .unwrap(); assert_eq!((cropped.get_width(), cropped.get_height()), (40, 40)); @@ -410,7 +404,60 @@ fn test_crop_window_is_positioned_by_gravity() { assert_eq!( rgba_pixel(&decoded, 20, 20), expected, - "gravity {gravity:?} selected the wrong quadrant" + "gravity {kind:?} selected the wrong quadrant" + ); + } +} + +/// A crop with no gravity of its own falls back to the request's, which +/// defaults to centre. imgforge used to pin it to the top-left corner instead, +/// so the same URL cut a different part of the image than imgproxy did. +#[test] +fn test_crop_defaults_to_the_centre() { + init_vips(); + let source = create_quadrant_test_image(100, 100); + let img = VipsImage::new_from_buffer(&source, "").unwrap(); + + let cropped = transform::crop_image( + img, + &Crop { + width: 40.0, + height: 40.0, + gravity: None, + }, + &Gravity::default(), + ) + .unwrap(); + + // A centred 40x40 window straddles the quadrant boundary, so its own + // corners land one in each quadrant. + let decoded = decode_rgba(&cropped); + assert_eq!(rgba_pixel(&decoded, 0, 0), [255, 0, 0, 255]); + assert_eq!(rgba_pixel(&decoded, 39, 0), [0, 255, 0, 255]); + assert_eq!(rgba_pixel(&decoded, 0, 39), [0, 0, 255, 255]); + assert_eq!(rgba_pixel(&decoded, 39, 39), [255, 255, 0, 255]); +} + +/// Crop extents below 1 are a fraction of the source, which is what lets one +/// URL cut the same proportion out of sources of different sizes. +#[test] +fn test_fractional_crop_extents_scale_with_the_source() { + init_vips(); + for (width, height) in [(100u32, 60u32), (400, 240)] { + let img = image_from(create_test_image(width, height)); + let cropped = transform::crop_image( + img, + &Crop { + width: 0.5, + height: 0.25, + gravity: None, + }, + &Gravity::default(), + ) + .unwrap(); + assert_eq!( + (cropped.get_width() as u32, cropped.get_height() as u32), + (width / 2, height / 4) ); } } @@ -424,13 +471,12 @@ fn test_crop_zero_means_full_extent_and_oversized_clamps() { let img = VipsImage::new_from_buffer(&source, "").unwrap(); let cropped = transform::crop_image( img, - Crop { - x: 0, - y: 0, - width: 0, - height: 30, + &Crop { + width: 0.0, + height: 30.0, gravity: None, }, + &Gravity::default(), ) .unwrap(); assert_eq!((cropped.get_width(), cropped.get_height()), (100, 30)); @@ -439,13 +485,12 @@ fn test_crop_zero_means_full_extent_and_oversized_clamps() { let img = VipsImage::new_from_buffer(&source, "").unwrap(); let cropped = transform::crop_image( img, - Crop { - x: 0, - y: 0, - width: 5000, - height: 5000, + &Crop { + width: 5000.0, + height: 5000.0, gravity: None, }, + &Gravity::default(), ) .unwrap(); assert_eq!((cropped.get_width(), cropped.get_height()), (100, 60)); @@ -562,3 +607,67 @@ fn test_trim_detects_the_background_on_a_16_bit_source() { let trimmed = transform::apply_trim(deep, &trim(10.0, None, false, false)).unwrap(); assert_eq!((trimmed.get_width(), trimmed.get_height()), (100, 60)); } + +/// libvips reports every 8-bit three-band image as `Srgb`, whatever its actual +/// primaries — the real space lives in the embedded profile. Deciding on the +/// interpretation alone let a wide-gamut source through untransformed, so its +/// numbers were read as sRGB and came out oversaturated. +#[test] +fn a_wide_gamut_source_is_converted_through_its_profile() { + init_vips(); + + let plain = image_from(create_test_image_jpeg(16, 16)); + assert!( + matches!(plain.get_interpretation(), Ok(ops::Interpretation::Srgb)), + "the premise: an ordinary JPEG already reports as sRGB" + ); + + // Tag the same pixels as Display P3. The interpretation does not change — + // that is exactly the trap — but the numbers now mean something different. + let p3 = ops::icc_transform_with_opts( + &plain, + "p3", + &ops::IccTransformOptions { + input_profile: "srgb".to_string(), + intent: ops::Intent::Relative, + ..Default::default() + }, + ); + let Ok(p3) = p3 else { + // A libvips build without the P3 profile cannot exercise this. + eprintln!("skipping: no P3 profile available in this libvips build"); + return; + }; + assert!( + matches!(p3.get_interpretation(), Ok(ops::Interpretation::Srgb)), + "a P3 image still reports as sRGB, which is why the enum cannot decide" + ); + + let converted = colorspace::to_processing(ops::copy(&p3).unwrap(), false).unwrap(); + + // Round-tripping P3 back to sRGB has to restore the original pixels. Left + // untransformed, the P3 numbers would survive unchanged and read as sRGB. + let original = decode_rgba(&plain); + let round_tripped = decode_rgba(&converted); + let untransformed = decode_rgba(&p3); + + let delta = |a: &image::RgbaImage, b: &image::RgbaImage| -> f64 { + a.pixels() + .zip(b.pixels()) + .map(|(x, y)| (f64::from(x[0]) - f64::from(y[0])).abs()) + .sum::() + / a.pixels().len() as f64 + }; + + let recovered = delta(&original, &round_tripped); + let skipped = delta(&original, &untransformed); + assert!( + recovered < skipped, + "converting through the profile must move the pixels back toward the original \ + (recovered delta {recovered:.2} should beat untransformed {skipped:.2})" + ); + assert!( + recovered < 4.0, + "the round trip should land close to the original, got {recovered:.2}" + ); +} diff --git a/src/processing/tests/options_parse_tests.rs b/src/processing/tests/options_parse_tests.rs index 26360cb..813ebc8 100644 --- a/src/processing/tests/options_parse_tests.rs +++ b/src/processing/tests/options_parse_tests.rs @@ -1,5 +1,7 @@ use crate::limits::{MaxResultDimension, MaxSourceFileSize, MaxSourceResolution}; -use crate::processing::options::{parse_all_options, Gravity, OptionParseError, ProcessingOption}; +use crate::processing::options::{ + parse_all_options, Gravity, GravityType, OptionParseError, ProcessingOption, ResizingType, WatermarkPosition, Zoom, +}; use crate::processing::presets::parse_options_string; use crate::processing::utils; use base64::Engine as _; @@ -21,7 +23,7 @@ fn test_parse_resize_option() { }]; let parsed = parse_all_options(options).unwrap(); let resize = parsed.resize.unwrap(); - assert_eq!(resize.resizing_type, "fill"); + assert_eq!(resize.resizing_type, ResizingType::Fill); assert_eq!(resize.width, 300); assert_eq!(resize.height, 200); } @@ -129,7 +131,7 @@ fn test_parse_extend_option() { args: vec!["1".to_string()], }]; let parsed = parse_all_options(options).unwrap(); - assert!(parsed.extend); + assert!(parsed.extend.enabled); } #[test] @@ -139,7 +141,7 @@ fn test_parse_gravity_option() { args: vec!["no".to_string()], }]; let parsed = parse_all_options(options).unwrap(); - assert_eq!(parsed.gravity, Some(Gravity::North)); + assert_eq!(parsed.gravity, Some(Gravity::new(GravityType::North))); } #[test] @@ -149,7 +151,7 @@ fn test_parse_imgproxy_gravity_alias() { args: vec!["soea".to_string()], }]; let parsed = parse_all_options(options).unwrap(); - assert_eq!(parsed.gravity, Some(Gravity::SouthEast)); + assert_eq!(parsed.gravity, Some(Gravity::new(GravityType::SouthEast))); } #[test] @@ -170,9 +172,9 @@ fn test_parse_crop_option() { }]; let parsed = parse_all_options(options).unwrap(); let crop = parsed.crop.unwrap(); - assert_eq!(crop.width, 100); - assert_eq!(crop.height, 150); - assert_eq!(crop.gravity, Some(Gravity::SouthEast)); + assert_eq!(crop.width, 100.0); + assert_eq!(crop.height, 150.0); + assert_eq!(crop.gravity, Some(Gravity::new(GravityType::SouthEast))); } #[test] @@ -353,14 +355,6 @@ fn test_imgforge_only_spellings_are_not_accepted() { name: "cache_buster".to_string(), args: vec!["legacy".to_string()], }, - ProcessingOption { - name: "min_width".to_string(), - args: vec!["500".to_string()], - }, - ProcessingOption { - name: "min_height".to_string(), - args: vec!["600".to_string()], - }, ProcessingOption { name: "px".to_string(), args: vec!["10".to_string()], @@ -373,12 +367,34 @@ fn test_imgforge_only_spellings_are_not_accepted() { .unwrap(); assert_eq!(parsed.cache_buster, None); - assert_eq!(parsed.min_width, None); - assert_eq!(parsed.min_height, None); assert_eq!(parsed.pixelate, None); assert!(parsed.resize.is_none()); } +/// imgproxy spells these with underscores while imgforge historically used +/// hyphens. Both are accepted, so a URL written against either documentation +/// works. +#[test] +fn test_min_dimension_spellings_are_interchangeable() { + for name in ["min-width", "min_width", "mw"] { + let parsed = parse_all_options(vec![ProcessingOption { + name: name.to_string(), + args: vec!["500".to_string()], + }]) + .unwrap(); + assert_eq!(parsed.min_width, Some(500), "{name} should set min_width"); + } + + for name in ["min-height", "min_height", "mh"] { + let parsed = parse_all_options(vec![ProcessingOption { + name: name.to_string(), + args: vec!["600".to_string()], + }]) + .unwrap(); + assert_eq!(parsed.min_height, Some(600), "{name} should set min_height"); + } +} + #[test] fn test_parse_min_width_option() { let options = vec![ProcessingOption { @@ -406,7 +422,7 @@ fn test_parse_zoom_option() { args: vec!["1.5".to_string()], }]; let parsed = parse_all_options(options).unwrap(); - assert_eq!(parsed.zoom, Some(1.5)); + assert_eq!(parsed.zoom, Some(Zoom { x: 1.5, y: 1.5 })); } #[test] @@ -590,7 +606,7 @@ fn test_parse_watermark_option() { let parsed = parse_all_options(options).unwrap(); let watermark = parsed.watermark.unwrap(); assert_eq!(watermark.opacity, 0.5); - assert_eq!(watermark.position, "ce"); + assert_eq!(watermark.position, WatermarkPosition::Anchor(GravityType::Center)); } // Error handling tests @@ -602,7 +618,7 @@ fn test_parse_resize_type_only() { }]; let parsed = parse_all_options(options).unwrap(); let resize = parsed.resize.unwrap(); - assert_eq!(resize.resizing_type, "fill"); + assert_eq!(resize.resizing_type, ResizingType::Fill); assert_eq!(resize.width, 0); assert_eq!(resize.height, 0); } @@ -616,7 +632,10 @@ fn test_parse_resizing_type_accepts_supported_values() { }]; let parsed = parse_all_options(options).expect("supported resizing type"); - assert_eq!(parsed.resize.unwrap().resizing_type, value); + assert_eq!( + parsed.resize.unwrap().resizing_type, + value.parse::().unwrap() + ); } } @@ -671,11 +690,11 @@ fn test_parse_resize_meta_enlarge_extend() { }]; let parsed = parse_all_options(options).unwrap(); let resize = parsed.resize.unwrap(); - assert_eq!(resize.resizing_type, "fit"); + assert_eq!(resize.resizing_type, ResizingType::Fit); assert_eq!(resize.width, 640); assert_eq!(resize.height, 480); assert!(parsed.enlarge); - assert!(parsed.extend); + assert!(parsed.extend.enabled); } #[test] @@ -687,7 +706,7 @@ fn test_parse_resize_meta_enlarge_only() { let parsed = parse_all_options(options).unwrap(); assert!(parsed.resize.is_none()); assert!(parsed.enlarge); - assert!(!parsed.extend); + assert!(!parsed.extend.enabled); } #[test] @@ -768,11 +787,23 @@ fn test_parse_crop_invalid_args() { assert!(parse_all_options(options).is_err()); } +/// Padding follows the CSS shorthand, as imgproxy's does: three values mean +/// top, then left-and-right, then bottom. +#[test] +fn test_parse_padding_three_values_use_the_css_shorthand() { + let parsed = parse_all_options(vec![ProcessingOption { + name: "padding".to_string(), + args: vec!["10".to_string(), "20".to_string(), "30".to_string()], + }]) + .unwrap(); + assert_eq!(parsed.padding, Some((10, 20, 30, 20))); +} + #[test] fn test_parse_padding_invalid_count() { let options = vec![ProcessingOption { name: "padding".to_string(), - args: vec!["10".to_string(), "20".to_string(), "30".to_string()], + args: (0..5).map(|value| value.to_string()).collect(), }]; assert!(parse_all_options(options).is_err()); } @@ -901,7 +932,7 @@ fn test_parse_size_option() { let parsed = parse_all_options(options).unwrap(); assert!(parsed.resize.is_some()); let resize = parsed.resize.unwrap(); - assert_eq!(resize.resizing_type, "fit"); + assert_eq!(resize.resizing_type, ResizingType::Fit); assert_eq!(resize.width, 640); assert_eq!(resize.height, 480); } @@ -929,11 +960,11 @@ fn test_parse_size_meta_full() { }]; let parsed = parse_all_options(options).unwrap(); let resize = parsed.resize.unwrap(); - assert_eq!(resize.resizing_type, "fit"); + assert_eq!(resize.resizing_type, ResizingType::Fit); assert_eq!(resize.width, 320); assert_eq!(resize.height, 240); assert!(parsed.enlarge); - assert!(parsed.extend); + assert!(parsed.extend.enabled); } #[test] @@ -945,7 +976,7 @@ fn test_parse_size_meta_enlarge_only() { let parsed = parse_all_options(options).unwrap(); assert!(parsed.resize.is_none()); assert!(parsed.enlarge); - assert!(!parsed.extend); + assert!(!parsed.extend.enabled); } #[test] @@ -961,10 +992,10 @@ fn test_parse_size_short_alias_s() { }]; let parsed = parse_all_options(options).unwrap(); let resize = parsed.resize.unwrap(); - assert_eq!(resize.resizing_type, "fit"); + assert_eq!(resize.resizing_type, ResizingType::Fit); assert_eq!(resize.width, 1024); assert_eq!(resize.height, 0); - assert!(parsed.extend); + assert!(parsed.extend.enabled); assert!(parsed.enlarge); } @@ -977,7 +1008,7 @@ fn test_parse_width_default_zero() { let parsed = parse_all_options(options).unwrap(); assert_eq!(parsed.width, Some(0)); let resize = parsed.resize.unwrap(); - assert_eq!(resize.resizing_type, "fit"); + assert_eq!(resize.resizing_type, ResizingType::Fit); assert_eq!(resize.width, 0); assert_eq!(resize.height, 0); } @@ -1114,3 +1145,76 @@ fn test_parse_trim_rejects_bad_input() { assert!(parse_all_options(options).is_err(), "accepted invalid trim"); } } + +/// The output format is canonicalised before the encoder sees it, so a +/// `format_quality` key spelled with an alias was looked up under the canonical +/// name, missed, and silently fell back to the default quality. +#[test] +fn format_quality_keys_are_canonicalised_like_the_output_format() { + let quality_for = |option: &str, lookup: &str| { + let parsed = parse_all_options(vec![ProcessingOption { + name: "format_quality".to_string(), + args: option.split(':').map(str::to_string).collect(), + }]) + .expect("format_quality should parse"); + parsed.save.format_quality.get(lookup).copied() + }; + + for (alias, canonical) in [("tif", "tiff"), ("jpg", "jpeg"), ("heic", "heif")] { + assert_eq!( + quality_for(&format!("{alias}:20"), canonical), + Some(20), + "{alias} should be stored under {canonical}" + ); + // And the canonical spelling still works unchanged. + assert_eq!(quality_for(&format!("{canonical}:30"), canonical), Some(30)); + } + + // A name that is not a format at all is kept as written rather than being + // rewritten into something it is not. + assert_eq!(quality_for("notaformat:40", "notaformat"), Some(40)); +} + +/// `disable_animation` collapses the source to one frame by definition, so it +/// outranks an explicit page count. Letting `pages` win produced an animation +/// from a request that had asked in as many words for it not to be one. +#[test] +fn disable_animation_outranks_an_explicit_page_count() { + use crate::processing::animation::LoadPlan; + use crate::processing::options::ParsedOptions; + + let plan = |pages: Option, disable: bool, page: Option| { + LoadPlan::resolve( + &ParsedOptions { + pages, + disable_animation: disable, + page, + ..ParsedOptions::default() + }, + Some("gif"), + "gif", + ) + }; + + // Both set: the disable wins. `None` here is not "no opinion" — it means the + // loader's own default is already right, and that default reads one page. + // Either way the request loads a single frame, which is what matters. + let effective_count = |plan: Option| plan.map_or(Some(1), |plan| plan.count); + assert_eq!(effective_count(plan(Some(5), true, None)), Some(1)); + + // The starting page is a separate question and is still honoured. + let from_third = plan(Some(5), true, Some(2)).expect("a plan is needed"); + assert_eq!((from_third.page, from_third.count), (2, Some(1))); + + // Without the disable, an explicit count is respected as before. + let counted = plan(Some(5), false, None).expect("a plan is needed"); + assert_eq!(counted.count, Some(5)); + + // And an animation-capable output with neither reads every frame: the plan + // asks for all pages rather than falling back to the one-page default. + assert_eq!( + plan(None, false, Some(1)).map(|p| p.count), + Some(None), + "an animated output with no disable should read every frame" + ); +} diff --git a/src/processing/tests/padding_extend_tests.rs b/src/processing/tests/padding_extend_tests.rs index aaa4851..caed735 100644 --- a/src/processing/tests/padding_extend_tests.rs +++ b/src/processing/tests/padding_extend_tests.rs @@ -1,4 +1,4 @@ -use crate::processing::options::Gravity; +use crate::processing::options::{Gravity, GravityType}; use crate::processing::transform::{self, TransformError}; use libvips::VipsImage; @@ -8,7 +8,15 @@ use super::tests_support::*; fn test_extend_image() { init_vips(); let img = image_from(create_test_image(100, 100)); - let extended_img = transform::extend_image(img, 200, 200, &Some(Gravity::Center), &Some([0, 0, 0, 0])).unwrap(); + let extended_img = transform::extend_image( + img, + 200, + 200, + &Gravity::new(GravityType::Center), + &Some([0, 0, 0, 0]), + 1.0, + ) + .unwrap(); assert_eq!(extended_img.get_width(), 200); assert_eq!(extended_img.get_height(), 200); } @@ -44,17 +52,18 @@ fn test_apply_padding_position_and_background_color() { fn test_extend_image_background_and_gravity_positions() { init_vips(); let cases = [ - (Gravity::Center, 2, 2), - (Gravity::North, 2, 0), - (Gravity::South, 2, 4), - (Gravity::East, 4, 2), - (Gravity::West, 0, 2), + (GravityType::Center, 2, 2), + (GravityType::North, 2, 0), + (GravityType::South, 2, 4), + (GravityType::East, 4, 2), + (GravityType::West, 0, 2), ]; for (gravity, origin_x, origin_y) in cases { let source_bytes = create_quadrant_test_image(4, 4); let img = VipsImage::new_from_buffer(&source_bytes, "").unwrap(); - let extended = transform::extend_image(img, 8, 8, &Some(gravity), &Some([10, 20, 30, 255])).unwrap(); + let extended = + transform::extend_image(img, 8, 8, &Gravity::new(gravity), &Some([10, 20, 30, 255]), 1.0).unwrap(); assert_eq!(extended.get_width(), 8); assert_eq!(extended.get_height(), 8); @@ -62,10 +71,10 @@ fn test_extend_image_background_and_gravity_positions() { assert_eq!(rgba_pixel(&decoded, origin_x, origin_y), [255, 0, 0, 255]); let bg_probe = match gravity { - Gravity::North => (0, 7), - Gravity::South => (0, 0), - Gravity::East => (0, 0), - Gravity::West => (7, 0), + GravityType::North => (0, 7), + GravityType::South => (0, 0), + GravityType::East => (0, 0), + GravityType::West => (7, 0), _ => (0, 0), }; assert_eq!(rgba_pixel(&decoded, bg_probe.0, bg_probe.1), [10, 20, 30, 255]); @@ -73,17 +82,54 @@ fn test_extend_image_background_and_gravity_positions() { } #[test] -fn test_extend_image_returns_error_when_target_smaller_than_source() { +fn test_extend_grows_only_the_axes_that_are_short() { init_vips(); + // A target smaller on one axis and larger on the other extends only the + // larger one. Refusing the whole operation — which is what imgforge used to + // do — dropped the padding a caller had legitimately asked for. let img = image_from(create_test_image(100, 80)); - let result = transform::extend_image(img, 90, 120, &Some(Gravity::Center), &Some([0, 0, 0, 0])); + let extended = transform::extend_image( + img, + 90, + 120, + &Gravity::new(GravityType::Center), + &Some([0, 0, 0, 0]), + 1.0, + ) + .expect("a partially smaller target extends the axis that is short"); + assert_eq!(extended.get_width(), 100); + assert_eq!(extended.get_height(), 120); +} + +#[test] +fn test_extend_leaves_an_image_alone_when_nothing_is_short() { + init_vips(); + let img = image_from(create_test_image(100, 80)); + let extended = transform::extend_image(img, 50, 40, &Gravity::new(GravityType::Center), &None, 1.0).unwrap(); + assert_eq!((extended.get_width(), extended.get_height()), (100, 80)); +} + +#[test] +fn test_extend_rejects_a_canvas_beyond_vips_limits() { + init_vips(); + // extend carried the same u32-to-i32 cast that made padding wrap negative. + // It was not reachable the same way, but an unbounded target is still an + // unbounded allocation request, and it must be refused rather than cast. + let img = image_from(create_test_image(64, 64)); + let err = transform::extend_image(img, 4_000_000_000, 64, &Gravity::default(), &None, 1.0) + .expect_err("a canvas this size must be refused, not silently wrapped"); + assert!(matches!( - result, - Err(TransformError::InvalidArgument { + err, + TransformError::InvalidArgument { operation: "extend", - ref message, - }) if message.contains("must be at least source") + .. + } )); + assert!( + err.to_string().contains("exceeds the maximum"), + "error should name the limit, got: {err}" + ); } #[test] @@ -99,14 +145,15 @@ fn test_padding_with_background_color() { fn test_extend_with_different_gravities() { init_vips(); for gravity in [ - Gravity::North, - Gravity::South, - Gravity::East, - Gravity::West, - Gravity::Center, + GravityType::North, + GravityType::South, + GravityType::East, + GravityType::West, + GravityType::Center, ] { let img = image_from(create_test_image(100, 100)); - let extended = transform::extend_image(img, 200, 200, &Some(gravity), &Some([0, 0, 0, 0])).unwrap(); + let extended = + transform::extend_image(img, 200, 200, &Gravity::new(gravity), &Some([0, 0, 0, 0]), 1.0).unwrap(); assert_eq!(extended.get_width(), 200); assert_eq!(extended.get_height(), 200); } diff --git a/src/processing/tests/pipeline_tests.rs b/src/processing/tests/pipeline_tests.rs index d1c751b..b6201be 100644 --- a/src/processing/tests/pipeline_tests.rs +++ b/src/processing/tests/pipeline_tests.rs @@ -1,4 +1,6 @@ -use crate::processing::options::{Crop, Gravity, ParsedOptions, Resize, Watermark}; +use crate::processing::options::{ + Crop, Extend, Gravity, GravityType, ParsedOptions, Resize, ResizingType, Watermark, WatermarkPosition, Zoom, +}; use crate::processing::process_image; use crate::processing::save; use crate::processing::transform; @@ -14,19 +16,17 @@ fn test_crop_then_resize() { init_vips(); let img = image_from(create_test_image(400, 400)); let crop = Crop { - x: 50, - y: 50, - width: 200, - height: 200, + width: 200.0, + height: 200.0, gravity: None, }; - let cropped = transform::crop_image(img, crop).unwrap(); + let cropped = transform::crop_image(img, &crop, &crop.gravity.unwrap_or_default()).unwrap(); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 100, height: 100, }; - let final_img = transform::apply_resize(cropped, &resize, &None, None, true).unwrap(); + let final_img = transform::apply_resize(cropped, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!(final_img.get_width(), 100); assert_eq!(final_img.get_height(), 100); } @@ -36,11 +36,11 @@ fn test_resize_then_blur() { init_vips(); let img = image_from(create_test_image(200, 200)); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 100, height: 100, }; - let resized = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let resized = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); let blurred = transform::apply_blur(resized, 3.0).unwrap(); assert_eq!(blurred.get_width(), 100); assert_eq!(blurred.get_height(), 100); @@ -51,11 +51,11 @@ fn test_resize_then_sharpen() { init_vips(); let img = image_from(create_test_image(200, 200)); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 300, height: 300, }; - let resized = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let resized = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); let sharpened = transform::apply_sharpen(resized, 1.0).unwrap(); assert_eq!(sharpened.get_width(), 300); assert_eq!(sharpened.get_height(), 300); @@ -67,11 +67,11 @@ fn test_rotation_then_resize() { let img = image_from(create_test_image(100, 200)); let rotated = transform::apply_rotation(img, 90).unwrap(); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 100, height: 100, }; - let resized = transform::apply_resize(rotated, &resize, &None, None, true).unwrap(); + let resized = transform::apply_resize(rotated, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!(resized.get_width(), 100); assert_eq!(resized.get_height(), 50); } @@ -82,21 +82,19 @@ fn test_complex_pipeline_crop_resize_blur_rotate() { let img = image_from(create_test_image(400, 400)); let crop = Crop { - x: 50, - y: 50, - width: 300, - height: 300, + width: 300.0, + height: 300.0, gravity: None, }; - let img = transform::crop_image(img, crop).unwrap(); + let img = transform::crop_image(img, &crop, &crop.gravity.unwrap_or_default()).unwrap(); assert_eq!(img.get_width(), 300); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 200, height: 200, }; - let img = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let img = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!(img.get_width(), 200); let img = transform::apply_blur(img, 2.0).unwrap(); @@ -111,11 +109,11 @@ fn test_complex_pipeline_resize_padding_watermark() { let img = image_from(create_test_image(200, 200)); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 150, height: 150, }; - let img = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let img = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); let img = transform::apply_padding(img, 10, 10, 10, 10, &Some([255, 255, 255, 255])).unwrap(); assert_eq!(img.get_width(), 170); @@ -124,7 +122,8 @@ fn test_complex_pipeline_resize_padding_watermark() { let watermark = cached_watermark_from_bytes(create_test_image(30, 30)); let watermark_opts = Watermark { opacity: 0.7, - position: "soea".to_string(), + position: WatermarkPosition::parse("soea").unwrap(), + ..Watermark::default() }; let img = watermark::apply_watermark(img, &watermark, &watermark_opts, None).unwrap(); assert_eq!(img.get_width(), 170); @@ -137,13 +136,16 @@ fn test_process_image_extend_uses_current_dimensions_after_min_height() { let img = VipsImage::new_from_buffer(&source_bytes, "").unwrap(); let parsed_options = ParsedOptions { resize: Some(Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 100, height: 200, }), format: Some("png".to_string()), enlarge: true, - extend: true, + extend: Extend { + enabled: true, + gravity: None, + }, min_height: Some(150), ..ParsedOptions::default() }; @@ -164,7 +166,7 @@ fn test_max_result_dimension_rejects_oversized_output() { let img = VipsImage::new_from_buffer(&source_bytes, "").unwrap(); let parsed_options = ParsedOptions { resize: Some(Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 4000, height: 4000, }), @@ -189,7 +191,7 @@ fn test_max_result_dimension_allows_output_within_limit() { let img = VipsImage::new_from_buffer(&source_bytes, "").unwrap(); let parsed_options = ParsedOptions { resize: Some(Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 500, height: 500, }), @@ -235,7 +237,7 @@ fn test_fit_inside_a_square_box_downscales_a_wide_source() { let img = VipsImage::new_from_buffer(&source_bytes, "").unwrap(); let parsed_options = ParsedOptions { resize: Some(Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 500, height: 500, }), @@ -258,7 +260,7 @@ fn test_load_shrink_never_undershoots_the_target() { let plan = |w: u32, h: u32| ParsedOptions { resize: Some(Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: w, height: h, }), @@ -293,7 +295,7 @@ fn test_load_shrink_declines_when_it_cannot_reason_about_the_target() { let with = |f: fn(&mut ParsedOptions)| { let mut o = ParsedOptions { resize: Some(Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 1000, height: 1000, }), @@ -308,11 +310,9 @@ fn test_load_shrink_declines_when_it_cannot_reason_about_the_target() { // would move the region being cut. assert_eq!( with(|o| o.crop = Some(Crop { - x: 0, - y: 0, - width: 50, - height: 50, - gravity: None + width: 50.0, + height: 50.0, + gravity: None, })), 1 ); @@ -322,7 +322,11 @@ fn test_load_shrink_declines_when_it_cannot_reason_about_the_target() { assert_eq!(with(|o| o.resize = None), 1); // Growth after the resize has to be respected, not shrunk away. assert_eq!(with(|o| o.dpr = Some(2.0)), 2, "dpr doubles the pixels needed"); - assert_eq!(with(|o| o.zoom = Some(2.0)), 2, "zoom doubles the pixels needed"); + assert_eq!( + with(|o| o.zoom = Some(Zoom { x: 2.0, y: 2.0 })), + 2, + "zoom doubles the pixels needed" + ); assert_eq!( with(|o| o.min_width = Some(2000)), 2, @@ -338,7 +342,7 @@ fn test_shrink_on_load_does_not_change_output_dimensions() { let source_bytes = Bytes::from(create_test_image_jpeg(2000, 1600)); let options = || ParsedOptions { resize: Some(Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 200, height: 200, }), @@ -375,7 +379,7 @@ fn test_load_shrink_declines_for_force_with_an_unset_axis() { let plan = |kind: &str, w: u32, h: u32| ParsedOptions { resize: Some(Resize { - resizing_type: kind.to_string(), + resizing_type: kind.parse().unwrap(), width: w, height: h, }), @@ -411,7 +415,7 @@ fn test_load_shrink_uses_displayed_dimensions_for_rotated_sources() { let options = ParsedOptions { resize: Some(Resize { - resizing_type: "fill".to_string(), + resizing_type: ResizingType::Fill, width: 2000, height: 1000, }), @@ -467,7 +471,7 @@ fn test_webp_load_scale_never_undershoots_the_target() { ] { let options = ParsedOptions { resize: Some(Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: tw, height: th, }), @@ -496,7 +500,7 @@ fn test_webp_scale_is_finer_than_the_jpeg_shrink() { let options = ParsedOptions { resize: Some(Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 1000, height: 1000, }), @@ -538,14 +542,12 @@ fn test_load_shrink_measures_the_crop_region_not_the_source() { // ParsedOptions is not Clone, so each case is built fresh. let plan = |crop: Option<(u32, u32)>| ParsedOptions { crop: crop.map(|(w, h)| Crop { - x: 0, - y: 0, - width: w, - height: h, + width: f64::from(w), + height: f64::from(h), gravity: None, }), resize: Some(Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 500, height: 375, }), @@ -571,14 +573,12 @@ fn test_cropped_request_survives_a_reduced_decode() { let source_bytes = Bytes::from(create_test_image_jpeg(2000, 1600)); let options = || ParsedOptions { crop: Some(Crop { - x: 0, - y: 0, - width: 1000, - height: 800, - gravity: Some(Gravity::SouthEast), + width: 1000.0, + height: 800.0, + gravity: Some(Gravity::new(GravityType::SouthEast)), }), resize: Some(Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 250, height: 200, }), @@ -595,8 +595,8 @@ fn test_cropped_request_survives_a_reduced_decode() { let shrunk = VipsImage::new_from_buffer(&source_bytes, &format!("shrink={factor}")).unwrap(); let mut scaled_options = options(); if let Some(crop) = scaled_options.crop.as_mut() { - crop.width /= factor; - crop.height /= factor; + crop.width /= f64::from(factor); + crop.height /= f64::from(factor); } let reduced = process_image(shrunk, scaled_options, &source_bytes, None).unwrap(); @@ -618,7 +618,7 @@ fn test_trim_disables_scale_on_load() { let plan = |trim: Option| ParsedOptions { trim, resize: Some(Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 200, height: 200, }), diff --git a/src/processing/tests/resize_tests.rs b/src/processing/tests/resize_tests.rs index 6c36cae..57be10d 100644 --- a/src/processing/tests/resize_tests.rs +++ b/src/processing/tests/resize_tests.rs @@ -1,4 +1,4 @@ -use crate::processing::options::{Gravity, Resize}; +use crate::processing::options::{Gravity, GravityType, Resize, ResizingType}; use crate::processing::transform::{self, TransformError}; use super::tests_support::*; @@ -8,11 +8,11 @@ fn test_apply_resize_fit() { init_vips(); let img = image_from(create_test_image(400, 300)); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 200, height: 150, }; - let resized_img = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let resized_img = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!(resized_img.get_width(), 200); assert_eq!(resized_img.get_height(), 150); } @@ -22,11 +22,12 @@ fn test_apply_resize_fill() { init_vips(); let img = image_from(create_test_image(400, 300)); let resize = Resize { - resizing_type: "fill".to_string(), + resizing_type: ResizingType::Fill, width: 200, height: 200, }; - let resized_img = transform::apply_resize(img, &resize, &Some(Gravity::Center), None, true).unwrap(); + let resized_img = + transform::apply_resize(img, &resize, &Gravity::new(GravityType::Center), None, true, 1.0).unwrap(); assert_eq!(resized_img.get_width(), 200); assert_eq!(resized_img.get_height(), 200); } @@ -36,11 +37,12 @@ fn test_apply_resize_fill_width_only() { init_vips(); let img = image_from(create_test_image(400, 300)); let resize = Resize { - resizing_type: "fill".to_string(), + resizing_type: ResizingType::Fill, width: 200, height: 0, }; - let resized_img = transform::apply_resize(img, &resize, &Some(Gravity::Center), None, true).unwrap(); + let resized_img = + transform::apply_resize(img, &resize, &Gravity::new(GravityType::Center), None, true, 1.0).unwrap(); assert_eq!(resized_img.get_width(), 200); assert_eq!(resized_img.get_height(), 150); } @@ -50,11 +52,12 @@ fn test_apply_resize_fill_height_only() { init_vips(); let img = image_from(create_test_image(400, 300)); let resize = Resize { - resizing_type: "fill".to_string(), + resizing_type: ResizingType::Fill, width: 0, height: 150, }; - let resized_img = transform::apply_resize(img, &resize, &Some(Gravity::Center), None, true).unwrap(); + let resized_img = + transform::apply_resize(img, &resize, &Gravity::new(GravityType::Center), None, true, 1.0).unwrap(); assert_eq!(resized_img.get_width(), 200); assert_eq!(resized_img.get_height(), 150); } @@ -64,11 +67,11 @@ fn test_apply_resize_force_width_only() { init_vips(); let img = image_from(create_test_image(400, 300)); let resize = Resize { - resizing_type: "force".to_string(), + resizing_type: ResizingType::Force, width: 200, height: 0, }; - let resized_img = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let resized_img = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!(resized_img.get_width(), 200); assert_eq!(resized_img.get_height(), 300); } @@ -78,11 +81,11 @@ fn test_apply_resize_force_height_only() { init_vips(); let img = image_from(create_test_image(400, 300)); let resize = Resize { - resizing_type: "force".to_string(), + resizing_type: ResizingType::Force, width: 0, height: 150, }; - let resized_img = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let resized_img = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!(resized_img.get_width(), 400); assert_eq!(resized_img.get_height(), 150); } @@ -92,37 +95,31 @@ fn test_apply_resize_force_zero_dimensions_error() { init_vips(); let img = image_from(create_test_image(400, 300)); let resize = Resize { - resizing_type: "force".to_string(), + resizing_type: ResizingType::Force, width: 0, height: 0, }; - let result = transform::apply_resize(img, &resize, &None, None, true); + let result = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0); assert!(result.is_err()); } #[test] -fn test_apply_resize_unknown_type_error() { - init_vips(); - let img = image_from(create_test_image(400, 300)); - let resize = Resize { - resizing_type: "bogus".to_string(), - width: 200, - height: 100, - }; - let result = transform::apply_resize(img, &resize, &None, None, true); - assert!(matches!( - result, - Err(TransformError::InvalidArgument { - operation: "resize", - ref message, - }) if message.contains("Unknown resize type") - )); +fn test_unknown_resizing_type_is_rejected_at_parse_time() { + use crate::processing::options::{parse_all_options, ProcessingOption}; + + // The resizing type is an enum now, so an unknown one cannot reach the + // pipeline at all; the parser is where it has to be caught. + let result = parse_all_options(vec![ProcessingOption { + name: "resize".to_string(), + args: vec!["bogus".to_string(), "200".to_string(), "100".to_string()], + }]); + assert!(result.is_err(), "an unknown resizing type must be refused"); } #[test] fn test_resolve_resize_dimensions_rejects_both_zero() { let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 0, height: 0, }; @@ -139,7 +136,7 @@ fn test_resolve_resize_dimensions_rejects_both_zero() { #[test] fn test_resolve_resize_dimensions_fills_missing_side_for_fit() { let resize_w_only = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 200, height: 0, }; @@ -147,7 +144,7 @@ fn test_resolve_resize_dimensions_fills_missing_side_for_fit() { assert_eq!(dims, (200, 150)); let resize_h_only = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 0, height: 150, }; @@ -158,7 +155,7 @@ fn test_resolve_resize_dimensions_fills_missing_side_for_fit() { #[test] fn test_resolve_resize_dimensions_force_uses_source_for_missing_side() { let resize_w_only = Resize { - resizing_type: "force".to_string(), + resizing_type: ResizingType::Force, width: 200, height: 0, }; @@ -166,7 +163,7 @@ fn test_resolve_resize_dimensions_force_uses_source_for_missing_side() { assert_eq!(dims, (200, 300)); let resize_h_only = Resize { - resizing_type: "force".to_string(), + resizing_type: ResizingType::Force, width: 0, height: 150, }; @@ -180,11 +177,11 @@ fn test_resize_very_small_image() { init_vips(); let img = image_from(create_test_image(10, 10)); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 5, height: 5, }; - let resized_img = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let resized_img = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!(resized_img.get_width(), 5); assert_eq!(resized_img.get_height(), 5); } @@ -194,11 +191,11 @@ fn test_resize_extreme_scale_up() { init_vips(); let img = image_from(create_test_image(10, 10)); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 1000, height: 1000, }; - let resized_img = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let resized_img = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!(resized_img.get_width(), 1000); assert_eq!(resized_img.get_height(), 1000); } @@ -208,11 +205,11 @@ fn test_resize_extreme_aspect_ratio() { init_vips(); let img = image_from(create_test_image(100, 100)); let resize = Resize { - resizing_type: "fill".to_string(), + resizing_type: ResizingType::Fill, width: 1000, height: 10, }; - let resized_img = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let resized_img = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!(resized_img.get_width(), 1000); assert_eq!(resized_img.get_height(), 10); } @@ -221,19 +218,19 @@ fn test_resize_extreme_aspect_ratio() { fn test_resize_fill_with_different_gravities() { init_vips(); for gravity in [ - Gravity::North, - Gravity::South, - Gravity::East, - Gravity::West, - Gravity::Center, + Gravity::new(GravityType::North), + Gravity::new(GravityType::South), + Gravity::new(GravityType::East), + Gravity::new(GravityType::West), + Gravity::new(GravityType::Center), ] { let img = image_from(create_test_image(200, 100)); let resize = Resize { - resizing_type: "fill".to_string(), + resizing_type: ResizingType::Fill, width: 100, height: 100, }; - let resized = transform::apply_resize(img, &resize, &Some(gravity), None, true).unwrap(); + let resized = transform::apply_resize(img, &resize, &gravity, None, true, 1.0).unwrap(); assert_eq!(resized.get_width(), 100); assert_eq!(resized.get_height(), 100); } @@ -244,11 +241,19 @@ fn test_resize_fill_with_lanczos2_kernel() { init_vips(); let img = image_from(create_test_image(800, 600)); let resize = Resize { - resizing_type: "fill".to_string(), + resizing_type: ResizingType::Fill, width: 300, height: 400, }; - let resized = transform::apply_resize(img, &resize, &Some(Gravity::Center), Some("lanczos2"), true).unwrap(); + let resized = transform::apply_resize( + img, + &resize, + &Gravity::new(GravityType::Center), + Some("lanczos2"), + true, + 1.0, + ) + .unwrap(); assert_eq!(resized.get_width(), 300); assert_eq!(resized.get_height(), 400); } @@ -258,11 +263,11 @@ fn test_resize_fit_with_nearest_kernel() { init_vips(); let img = image_from(create_test_image(800, 600)); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 300, height: 400, }; - let resized = transform::apply_resize(img, &resize, &None, Some("nearest"), true).unwrap(); + let resized = transform::apply_resize(img, &resize, &Gravity::default(), Some("nearest"), true, 1.0).unwrap(); assert_eq!(resized.get_width(), 300); assert_eq!(resized.get_height(), 225); } @@ -273,11 +278,11 @@ fn test_resize_fit_width_only() { init_vips(); let img = image_from(create_test_image(200, 100)); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 100, height: 0, }; - let resized = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let resized = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!(resized.get_width(), 100); assert_eq!(resized.get_height(), 50); } @@ -287,11 +292,11 @@ fn test_resize_fit_height_only() { init_vips(); let img = image_from(create_test_image(200, 100)); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 0, height: 50, }; - let resized = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let resized = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!(resized.get_width(), 100); assert_eq!(resized.get_height(), 50); } @@ -301,11 +306,11 @@ fn test_resize_auto_portrait_to_portrait() { init_vips(); let img = image_from(create_test_image(100, 200)); let resize = Resize { - resizing_type: "auto".to_string(), + resizing_type: ResizingType::Auto, width: 50, height: 100, }; - let resized = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let resized = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!(resized.get_width(), 50); assert_eq!(resized.get_height(), 100); } @@ -315,11 +320,11 @@ fn test_resize_auto_landscape_to_landscape() { init_vips(); let img = image_from(create_test_image(200, 100)); let resize = Resize { - resizing_type: "auto".to_string(), + resizing_type: ResizingType::Auto, width: 100, height: 50, }; - let resized = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let resized = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!(resized.get_width(), 100); assert_eq!(resized.get_height(), 50); } @@ -329,11 +334,11 @@ fn test_resize_auto_portrait_to_landscape() { init_vips(); let img = image_from(create_test_image(100, 200)); let resize = Resize { - resizing_type: "auto".to_string(), + resizing_type: ResizingType::Auto, width: 150, height: 100, }; - let resized = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let resized = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); // Uses fit mode when orientations differ, fitting within 150x100 while keeping aspect. assert_eq!(resized.get_width(), 50); assert_eq!(resized.get_height(), 100); @@ -344,13 +349,13 @@ fn test_apply_resize_with_cubic_algorithm() { init_vips(); let img = image_from(create_test_image(400, 300)); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 200, height: 150, }; // Test with cubic - should also work - let resized_img2 = transform::apply_resize(img, &resize, &None, Some("cubic"), true).unwrap(); + let resized_img2 = transform::apply_resize(img, &resize, &Gravity::default(), Some("cubic"), true, 1.0).unwrap(); assert_eq!(resized_img2.get_width(), 200); assert_eq!(resized_img2.get_height(), 150); } @@ -360,12 +365,13 @@ fn test_apply_resize_with_invalid_kernel_falls_back_to_default() { init_vips(); let img = image_from(create_test_image(400, 300)); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 200, height: 150, }; - let resized_img = transform::apply_resize(img, &resize, &None, Some("not-a-kernel"), true).unwrap(); + let resized_img = + transform::apply_resize(img, &resize, &Gravity::default(), Some("not-a-kernel"), true, 1.0).unwrap(); assert_eq!(resized_img.get_width(), 200); assert_eq!(resized_img.get_height(), 150); } @@ -392,11 +398,11 @@ fn test_fit_downscales_even_when_the_box_is_taller_than_the_source() { for (sw, sh, tw, th, ew, eh) in cases { let img = image_from(create_test_image(sw, sh)); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: tw, height: th, }; - let out = transform::apply_resize(img, &resize, &None, None, false).unwrap(); + let out = transform::apply_resize(img, &resize, &Gravity::default(), None, false, 1.0).unwrap(); assert_eq!( (out.get_width(), out.get_height()), (ew, eh), @@ -411,16 +417,16 @@ fn test_fit_still_refuses_to_enlarge() { // Both axes would grow: the cap leaves the image alone. let img = image_from(create_test_image(100, 100)); let resize = Resize { - resizing_type: "fit".to_string(), + resizing_type: ResizingType::Fit, width: 500, height: 500, }; - let out = transform::apply_resize(img, &resize, &None, None, false).unwrap(); + let out = transform::apply_resize(img, &resize, &Gravity::default(), None, false, 1.0).unwrap(); assert_eq!((out.get_width(), out.get_height()), (100, 100)); // ...and enlarges when asked to. let img = image_from(create_test_image(100, 100)); - let out = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let out = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!((out.get_width(), out.get_height()), (500, 500)); } @@ -431,16 +437,16 @@ fn test_fill_crops_to_what_is_available_when_capped() { // image is not scaled, and the crop takes what exists: 500x100, not an error. let img = image_from(create_test_image(1000, 100)); let resize = Resize { - resizing_type: "fill".to_string(), + resizing_type: ResizingType::Fill, width: 500, height: 200, }; - let out = transform::apply_resize(img, &resize, &Some(Gravity::Center), None, false).unwrap(); + let out = transform::apply_resize(img, &resize, &Gravity::new(GravityType::Center), None, false, 1.0).unwrap(); assert_eq!((out.get_width(), out.get_height()), (500, 100)); // With enlargement allowed the box is filled exactly. let img = image_from(create_test_image(1000, 100)); - let out = transform::apply_resize(img, &resize, &Some(Gravity::Center), None, true).unwrap(); + let out = transform::apply_resize(img, &resize, &Gravity::new(GravityType::Center), None, true, 1.0).unwrap(); assert_eq!((out.get_width(), out.get_height()), (500, 200)); } @@ -455,15 +461,15 @@ fn test_force_caps_enlargement_while_keeping_the_requested_distortion() { // caller asked for. let img = image_from(create_test_image(1000, 100)); let resize = Resize { - resizing_type: "force".to_string(), + resizing_type: ResizingType::Force, width: 2000, height: 50, }; - let out = transform::apply_resize(img, &resize, &None, None, false).unwrap(); + let out = transform::apply_resize(img, &resize, &Gravity::default(), None, false, 1.0).unwrap(); assert_eq!((out.get_width(), out.get_height()), (1000, 25)); let img = image_from(create_test_image(1000, 100)); - let out = transform::apply_resize(img, &resize, &None, None, true).unwrap(); + let out = transform::apply_resize(img, &resize, &Gravity::default(), None, true, 1.0).unwrap(); assert_eq!((out.get_width(), out.get_height()), (2000, 50)); } @@ -475,11 +481,11 @@ fn test_resize_does_not_bleed_transparent_colour_into_visible_pixels() { init_vips(); let img = image_from(create_transparent_edge_image(100, 100)); let resize = Resize { - resizing_type: "force".to_string(), + resizing_type: ResizingType::Force, width: 10, height: 10, }; - let out = transform::apply_resize(img, &resize, &None, None, false).unwrap(); + let out = transform::apply_resize(img, &resize, &Gravity::default(), None, false, 1.0).unwrap(); let decoded = decode_rgba(&out); // Across the whole boundary, partially transparent pixels must still be @@ -505,11 +511,11 @@ fn test_resize_without_alpha_is_unaffected() { // The premultiply round trip must not disturb opaque images. let img = image_from(create_test_image_jpeg(100, 100)); let resize = Resize { - resizing_type: "force".to_string(), + resizing_type: ResizingType::Force, width: 50, height: 50, }; - let out = transform::apply_resize(img, &resize, &None, None, false).unwrap(); + let out = transform::apply_resize(img, &resize, &Gravity::default(), None, false, 1.0).unwrap(); assert_eq!((out.get_width(), out.get_height()), (50, 50)); let decoded = decode_rgba(&out); let [r, g, b, a] = rgba_pixel(&decoded, 25, 25); @@ -519,3 +525,62 @@ fn test_resize_without_alpha_is_unaffected() { "opaque red should survive unchanged, got {r},{g},{b},{a}" ); } + +/// A fill's absolute gravity offset is measured against the image the crop runs +/// on, and DPR is what sized that image. Passing a fixed scale of 1 left a 10px +/// nudge at 10px on a result twice as large, moving the window half as far as +/// the same URL moved it at 1x. +#[test] +fn fill_gravity_offsets_scale_with_dpr() { + use crate::processing::options::{Gravity, GravityType}; + + let resize = Resize { + resizing_type: ResizingType::Fill, + width: 100, + height: 100, + }; + let gravity = Gravity { + kind: GravityType::NorthWest, + x: 10.0, + y: 10.0, + }; + + // A 400x200 source filled to a 100x100 window leaves horizontal slack, so + // the offset decides which column the window starts at. + let at_1x = { + let img = image_from(create_quadrant_test_image(400, 200)); + transform::apply_resize(img, &resize, &gravity, None, true, 1.0).unwrap() + }; + let at_2x = { + let img = image_from(create_quadrant_test_image(400, 200)); + transform::apply_resize(img, &resize, &gravity, None, true, 2.0).unwrap() + }; + + // Same geometry either way; only the window's position differs. + assert_eq!((at_1x.get_width(), at_1x.get_height()), (100, 100)); + assert_eq!((at_2x.get_width(), at_2x.get_height()), (100, 100)); + + let one = collect_rgba_pixels(&decode_rgba(&at_1x)); + let two = collect_rgba_pixels(&decode_rgba(&at_2x)); + assert_ne!( + one, two, + "doubling the DPR scale must move the fill window, not leave it where 1x put it" + ); + + // And the scale genuinely reaches calc_position rather than being ignored: + // a centred gravity takes no offsets, so DPR changes nothing there. + let centred = Gravity::new(GravityType::Center); + let centred_1x = { + let img = image_from(create_quadrant_test_image(400, 200)); + transform::apply_resize(img, &resize, ¢red, None, true, 1.0).unwrap() + }; + let centred_2x = { + let img = image_from(create_quadrant_test_image(400, 200)); + transform::apply_resize(img, &resize, ¢red, None, true, 2.0).unwrap() + }; + assert_eq!( + collect_rgba_pixels(&decode_rgba(¢red_1x)), + collect_rgba_pixels(&decode_rgba(¢red_2x)), + "a gravity with no offsets has nothing for the DPR scale to act on" + ); +} diff --git a/src/processing/tests/save_tests.rs b/src/processing/tests/save_tests.rs index 82d9446..03e4d98 100644 --- a/src/processing/tests/save_tests.rs +++ b/src/processing/tests/save_tests.rs @@ -1,7 +1,7 @@ use crate::processing::options::SaveOptions; use crate::processing::save; use image::{ImageBuffer, Rgb}; -use libvips::{ops, VipsImage}; +use libvips::VipsImage; use super::tests_support::*; @@ -58,7 +58,7 @@ fn test_webp_save_lossless_applies() { options.webp.lossless = Some(true); let img = VipsImage::new_from_buffer(&base, "").unwrap(); - let lossless = save::save_image_with_options(img, "webp", 80, &options).unwrap(); + let lossless = save::save_image_with_options(img, "webp", 80, &options, None).unwrap(); let img = VipsImage::new_from_buffer(&base, "").unwrap(); let lossy = save::save_image(img, "webp", 80).unwrap(); @@ -87,7 +87,7 @@ fn test_webp_save_honors_max_bytes() { ..Default::default() }; let img = VipsImage::new_from_buffer(&base, "").unwrap(); - let bounded = save::save_image_with_options(img, "webp", 95, &options).unwrap(); + let bounded = save::save_image_with_options(img, "webp", 95, &options, None).unwrap(); assert!( bounded.len() <= budget && bounded.len() < unbounded.len(), @@ -102,24 +102,44 @@ fn test_webp_save_honors_max_bytes() { fn test_webp_save_suffix_carries_encoder_options() { let mut options = SaveOptions::default(); assert_eq!( - save::webp_save_suffix(80, ops::ForeignKeep::All, &options), + save::save_suffix("webp", 80, &options, None).unwrap(), ".webp[Q=80,keep=all]" ); options.webp.lossless = Some(true); options.webp.smart_subsample = Some(true); options.webp.preset = Some("photo".to_string()); + options.strip_metadata = Some(true); + options.strip_color_profile = Some(true); assert_eq!( - save::webp_save_suffix(90, ops::ForeignKeep::None, &options), + save::save_suffix("webp", 90, &options, None).unwrap(), ".webp[Q=90,lossless,smart-subsample,preset=photo,keep=none]" ); } +#[test] +fn test_webp_save_suffix_carries_the_animation_frame_height() { + // libvips stores an animation as one tall image; without the frame height + // the encoder writes a single very tall still instead of an animation. + let options = SaveOptions::default(); + assert_eq!( + save::save_suffix("webp", 80, &options, Some(40)).unwrap(), + ".webp[Q=80,page-height=40,keep=all]" + ); + + // A format that cannot hold more than one frame never gets the option, + // because naming it would be meaningless rather than merely redundant. + assert_eq!( + save::save_suffix("jpeg", 80, &options, Some(40)).unwrap(), + ".jpg[Q=80,optimize-coding,quant-table=0,subsample-mode=auto,keep=all]" + ); +} + #[test] fn test_webp_save_suffix_clamps_quality() { let options = SaveOptions::default(); assert_eq!( - save::webp_save_suffix(0, ops::ForeignKeep::All, &options), + save::save_suffix("webp", 0, &options, None).unwrap(), ".webp[Q=1,keep=all]" ); } @@ -131,7 +151,7 @@ fn test_webp_save_suffix_drops_unknown_preset() { let mut options = SaveOptions::default(); options.webp.preset = Some("photo],lossless".to_string()); assert_eq!( - save::webp_save_suffix(75, ops::ForeignKeep::All, &options), + save::save_suffix("webp", 75, &options, None).unwrap(), ".webp[Q=75,keep=all]" ); } @@ -140,28 +160,63 @@ fn test_webp_save_suffix_drops_unknown_preset() { fn test_heif_save_suffix_carries_encoder_options() { let mut options = SaveOptions::default(); assert_eq!( - save::heif_save_suffix("avif", "av1", 80, 7, ops::ForeignKeep::All, &options), + save::save_suffix("avif", 80, &options, None).unwrap(), ".avif[Q=80,compression=av1,effort=7,subsample-mode=auto,keep=all]" ); options.avif.no_subsample = Some(true); + options.strip_metadata = Some(true); + options.strip_color_profile = Some(true); assert_eq!( - save::heif_save_suffix("heif", "hevc", 80, 12, ops::ForeignKeep::None, &options), + save::save_suffix("heif", 100, &options, None).unwrap(), // effort clamps to the 0-9 libvips accepts - ".heif[Q=80,compression=hevc,effort=9,subsample-mode=off,keep=none]" + ".heif[Q=100,compression=hevc,effort=9,subsample-mode=off,keep=none]" ); } #[test] fn test_gif_save_suffix_carries_encoder_options() { + let options = SaveOptions::default(); assert_eq!( - save::gif_save_suffix(5, ops::ForeignKeep::All), + save::save_suffix("gif", 50, &options, None).unwrap(), ".gif[effort=5,keep=all]" ); // gifsave takes effort 1-10, unlike the 0-9 of the HEIF family assert_eq!( - save::gif_save_suffix(0, ops::ForeignKeep::None), - ".gif[effort=1,keep=none]" + save::save_suffix("gif", 1, &options, None).unwrap(), + ".gif[effort=1,keep=all]" + ); +} + +#[test] +fn stripping_one_kind_of_metadata_keeps_the_other() { + // The two strip options address different flags. Collapsing them into a + // single "keep nothing" — which a one-variant enum forces — meant asking + // to drop the colour profile also discarded the EXIF, and the reverse. + let mut options = SaveOptions { + strip_metadata: Some(true), + ..SaveOptions::default() + }; + + assert_eq!( + save::save_suffix("webp", 80, &options, None).unwrap(), + ".webp[Q=80,keep=icc]" + ); + + options.strip_metadata = Some(false); + options.strip_color_profile = Some(true); + assert_eq!( + save::save_suffix("webp", 80, &options, None).unwrap(), + ".webp[Q=80,keep=exif|xmp|iptc|other]" + ); + + // A gain map is what makes an image HDR, so preserving HDR has to keep it + // even while everything else is being stripped. + options.strip_metadata = Some(true); + options.preserve_hdr = Some(true); + assert_eq!( + save::save_suffix("avif", 80, &options, None).unwrap(), + ".avif[Q=80,compression=av1,effort=7,subsample-mode=auto,keep=gainmap]" ); } diff --git a/src/processing/tests/watermark_tests.rs b/src/processing/tests/watermark_tests.rs index 142db0a..03f3024 100644 --- a/src/processing/tests/watermark_tests.rs +++ b/src/processing/tests/watermark_tests.rs @@ -1,5 +1,5 @@ use crate::constants::ENV_WATERMARK_PATH; -use crate::processing::options::Watermark; +use crate::processing::options::{Watermark, WatermarkPosition}; use crate::processing::watermark; use bytes::Bytes; use libvips::VipsImage; @@ -19,7 +19,8 @@ fn test_apply_watermark() { let img = image_from(create_test_image(200, 200)); let watermark_opts = Watermark { opacity: 0.5, - position: "ce".to_string(), + position: WatermarkPosition::parse("ce").unwrap(), + ..Watermark::default() }; let watermarked_img = watermark::apply_watermark(img, &watermark, &watermark_opts, None).unwrap(); @@ -42,7 +43,8 @@ fn test_apply_watermark_prepared() { let img = VipsImage::new_from_buffer(&base, "").unwrap(); let watermark_opts = Watermark { opacity: 0.5, - position: "soea".to_string(), + position: WatermarkPosition::parse("soea").unwrap(), + ..Watermark::default() }; let watermarked = watermark::apply_watermark(img, &watermark, &watermark_opts, None).unwrap(); @@ -61,7 +63,8 @@ fn test_apply_watermark_prepared_matches_bytes_path() { let watermark_bytes = create_test_image(50, 50); let watermark_opts = Watermark { opacity: 0.5, - position: "ce".to_string(), + position: WatermarkPosition::parse("ce").unwrap(), + ..Watermark::default() }; // The base buffers must outlive the (lazily evaluated) pipelines: @@ -102,7 +105,8 @@ fn test_apply_watermark_prepared_rgb_watermark() { let img = VipsImage::new_from_buffer(&base, "").unwrap(); let watermark_opts = Watermark { opacity: 0.5, - position: "ce".to_string(), + position: WatermarkPosition::parse("ce").unwrap(), + ..Watermark::default() }; let watermarked = watermark::apply_watermark(img, &watermark, &watermark_opts, None).unwrap(); @@ -121,7 +125,8 @@ fn test_watermark_all_positions() { let img = image_from(create_test_image(200, 200)); let watermark_opts = Watermark { opacity: 0.5, - position: position.to_string(), + position: WatermarkPosition::parse(position).unwrap(), + ..Watermark::default() }; let watermarked = watermark::apply_watermark(img, &watermark, &watermark_opts, None).unwrap(); assert_eq!(watermarked.get_width(), 200); @@ -136,7 +141,8 @@ fn test_watermark_full_opacity() { let watermark = cached_watermark_from_bytes(create_test_image(50, 50)); let watermark_opts = Watermark { opacity: 1.0, - position: "ce".to_string(), + position: WatermarkPosition::parse("ce").unwrap(), + ..Watermark::default() }; let watermarked = watermark::apply_watermark(img, &watermark, &watermark_opts, None).unwrap(); assert_eq!(watermarked.get_width(), 200); @@ -150,7 +156,8 @@ fn test_watermark_zero_opacity() { let watermark = cached_watermark_from_bytes(create_test_image(50, 50)); let watermark_opts = Watermark { opacity: 0.0, - position: "ce".to_string(), + position: WatermarkPosition::parse("ce").unwrap(), + ..Watermark::default() }; let watermarked = watermark::apply_watermark(img, &watermark, &watermark_opts, None).unwrap(); assert_eq!(watermarked.get_width(), 200); diff --git a/src/processing/transform.rs b/src/processing/transform.rs deleted file mode 100644 index 600e451..0000000 --- a/src/processing/transform.rs +++ /dev/null @@ -1,800 +0,0 @@ -use crate::processing::options::{Adjust, Crop, Flip, Gravity, Resize, Trim}; -use crate::utils::read_exif_orientation; -use libvips::{ops, VipsImage}; -use thiserror::Error; -use tracing::debug; - -const SCALE_EPSILON: f64 = 1e-6; - -/// Largest coordinate libvips accepts for `embed`; anything beyond it is -/// rejected by the operation itself. -const VIPS_MAX_COORD: i64 = 1_000_000_000; - -/// Errors produced while transforming an image. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum TransformError { - #[error("{operation}: {source}")] - Vips { - operation: &'static str, - #[source] - source: libvips::error::Error, - }, - #[error("{message}")] - InvalidArgument { operation: &'static str, message: String }, -} - -impl TransformError { - fn invalid(operation: &'static str, message: impl Into) -> Self { - Self::InvalidArgument { - operation, - message: message.into(), - } - } -} - -fn vips(operation: &'static str) -> impl FnOnce(libvips::error::Error) -> TransformError { - move |source| TransformError::Vips { operation, source } -} - -/// Converts a resizing algorithm string to a libvips Kernel enum. -fn get_resize_kernel(algorithm: Option<&str>) -> ops::Kernel { - match algorithm.unwrap_or("lanczos3") { - "nearest" => ops::Kernel::Nearest, - "linear" => ops::Kernel::Linear, - "cubic" => ops::Kernel::Cubic, - "lanczos2" => ops::Kernel::Lanczos2, - "lanczos3" => ops::Kernel::Lanczos3, - _ => ops::Kernel::Lanczos3, // Default to lanczos3 - } -} - -fn bg_color_for_bands(bg_color: [u8; 4], bands: i32) -> Vec { - let luma = (0.299 * bg_color[0] as f64 + 0.587 * bg_color[1] as f64 + 0.114 * bg_color[2] as f64).round(); - match bands { - 4 => vec![ - bg_color[0] as f64, - bg_color[1] as f64, - bg_color[2] as f64, - bg_color[3] as f64, - ], - 3 => vec![bg_color[0] as f64, bg_color[1] as f64, bg_color[2] as f64], - 2 => vec![luma, bg_color[3] as f64], - 1 => vec![luma], - _ => vec![bg_color[0] as f64, bg_color[1] as f64, bg_color[2] as f64], - } -} - -/// Helper to resize using the requested algorithm, defaulting to lanczos3. -/// -/// Images carrying alpha are premultiplied for the duration of the scale. -/// libvips is explicit that `vips_resize` does not do this itself — "if your -/// image has an alpha channel, you should use vips_premultiply() on it first" — -/// and without it the kernel averages the colour of fully transparent pixels -/// into visible ones. Downscaling white-on-transparent that way drags the edge -/// toward whatever colour happens to sit in the invisible pixels, which shows up -/// as a dark halo around logos and cutouts once the result is composited. -pub fn resize_with_algorithm( - img: &VipsImage, - hscale: f64, - vscale: Option, - resizing_algorithm: Option<&str>, - error_context: &'static str, -) -> Result { - let options = ops::ResizeOptions { - kernel: get_resize_kernel(resizing_algorithm), - vscale: vscale.unwrap_or(hscale), - ..Default::default() - }; - - if !img.image_hasalpha() { - return ops::resize_with_opts(img, hscale, &options).map_err(vips(error_context)); - } - - // A source whose format vips cannot report is not one to guess at; fall - // back to the plain resize rather than casting to something invented. - let Ok(source_format) = img.get_format() else { - return ops::resize_with_opts(img, hscale, &options).map_err(vips(error_context)); - }; - let premultiplied = ops::premultiply(img).map_err(vips(error_context))?; - let resized = ops::resize_with_opts(&premultiplied, hscale, &options).map_err(vips(error_context))?; - let restored = ops::unpremultiply(&resized).map_err(vips(error_context))?; - - // premultiply/unpremultiply work in float; without casting back, every - // later step and the encoder would see a float image. - ops::cast(&restored, source_format).map_err(vips(error_context)) -} - -/// Applies EXIF rotation to an image based on orientation data. -pub fn apply_exif_rotation(image_bytes: &[u8], mut img: VipsImage) -> Result { - if let Some(orientation) = read_exif_orientation(image_bytes) { - debug!("Found EXIF orientation: {:?}", orientation); - img = apply_exif_orientation(img, orientation)?; - } - Ok(img) -} - -pub(crate) fn apply_exif_orientation(mut img: VipsImage, orientation: u32) -> Result { - match orientation { - 2 => img = ops::flip(&img, ops::Direction::Horizontal).map_err(vips("Error flipping horizontally"))?, - 3 => img = ops::rot(&img, ops::Angle::D180).map_err(vips("Error rotating 180"))?, - 4 => img = ops::flip(&img, ops::Direction::Vertical).map_err(vips("Error flipping vertically"))?, - 5 => { - img = ops::flip( - &ops::rot(&img, ops::Angle::D90).map_err(vips("Error rotating 90"))?, - ops::Direction::Horizontal, - ) - .map_err(vips("Error flipping after rotate"))? - } - 6 => img = ops::rot(&img, ops::Angle::D90).map_err(vips("Error rotating 90"))?, - 7 => { - img = ops::flip( - &ops::rot(&img, ops::Angle::D270).map_err(vips("Error rotating 270"))?, - ops::Direction::Horizontal, - ) - .map_err(vips("Error flipping after rotate"))? - } - 8 => img = ops::rot(&img, ops::Angle::D270).map_err(vips("Error rotating 270"))?, - _ => {} - } - Ok(img) -} - -/// Crops an image to the specified dimensions. -/// How many components `find_trim` expects in a background colour: one per -/// band, less the alpha if there is one. libvips accepts a single value or -/// exactly that many, and rejects anything else — three components against a -/// CMYK image fails with "vector must have 1 or 4 elements". -fn background_components(img: &VipsImage) -> usize { - let bands = usize::try_from(img.get_bands()).unwrap_or(1).max(1); - if img.image_hasalpha() { - bands.saturating_sub(1).max(1) - } else { - bands - } -} - -/// Reads the top-left pixel, to use as the background when the request does not -/// name one. imgproxy works this out from the image the same way; libvips on its -/// own would assume white, which never trims a dark border. -/// -/// Averaging a one-pixel band reads its value whatever the band format, so this -/// works on 16-bit sources as well as 8-bit — interpreting raw memory would -/// have meant knowing the layout of each format. -fn corner_pixel(img: &VipsImage, components: usize) -> Option> { - let corner = ops::extract_area(img, 0, 0, 1, 1).ok()?; - (0..components) - .map(|band| { - let band = ops::extract_band(&corner, i32::try_from(band).ok()?).ok()?; - ops::avg(&band).ok() - }) - .collect() -} - -/// Trims a uniform border. -/// -/// Note for callers: the trimmed size is not knowable in advance, which is why -/// scale-on-load steps aside when this is in play — there is no way to choose a -/// decode scale against an unknown result. -pub fn apply_trim(img: VipsImage, trim: &Trim) -> Result { - let components = background_components(&img); - let background = match trim.color { - // An explicit colour arrives as sRGB, which only lines up with a - // three-component image. Greyscale takes its luminance; anything else — - // CMYK, say — has no meaningful conversion, and guessing would trim the - // wrong thing silently. - Some(color) if components == 3 => vec![f64::from(color[0]), f64::from(color[1]), f64::from(color[2])], - Some(color) if components == 1 => { - vec![0.299 * f64::from(color[0]) + 0.587 * f64::from(color[1]) + 0.114 * f64::from(color[2])] - } - Some(_) => { - return Err(TransformError::invalid( - "trim", - format!( - "trim colour cannot be applied to a {components}-component image; omit it to detect the background instead" - ), - )) - } - None => corner_pixel(&img, components).unwrap_or_else(|| vec![255.0; components]), - }; - - let options = ops::FindTrimOptions { - threshold: trim.threshold, - background, - line_art: false, - }; - let (left, top, width, height) = ops::find_trim_with_opts(&img, &options).map_err(vips("Error finding trim"))?; - - // An image that is entirely background has nothing to keep. Returning it - // untouched beats handing back an empty or one-pixel image. - if width <= 0 || height <= 0 { - debug!("Trim found no content to keep; leaving the image alone"); - return Ok(img); - } - - let (src_width, src_height) = (img.get_width(), img.get_height()); - let (mut left, mut top, mut width, mut height) = (left, top, width, height); - - // "Equal" means the same amount comes off both sides, so the subject keeps - // its position rather than shifting toward whichever border was thicker. - if trim.equal_hor { - let margin = left.min(src_width - (left + width)); - left = margin; - width = src_width - 2 * margin; - } - if trim.equal_ver { - let margin = top.min(src_height - (top + height)); - top = margin; - height = src_height - 2 * margin; - } - - debug!("Trimming to {}x{} at ({}, {})", width, height, left, top); - ops::extract_area(&img, left, top, width, height).map_err(vips("Error trimming image")) -} - -pub fn crop_image(img: VipsImage, crop: Crop) -> Result { - let src_width = img.get_width() as u32; - let src_height = img.get_height() as u32; - let width = if crop.width == 0 { - src_width - } else { - crop.width.min(src_width) - }; - let height = if crop.height == 0 { - src_height - } else { - crop.height.min(src_height) - }; - let (x, y) = if let Some(gravity) = crop.gravity { - crop_origin_for_gravity(src_width, src_height, width, height, gravity) - } else { - (crop.x, crop.y) - }; - - ops::extract_area(&img, x as i32, y as i32, width as i32, height as i32).map_err(vips("Error cropping image")) -} - -fn crop_origin_for_gravity(src_width: u32, src_height: u32, width: u32, height: u32, gravity: Gravity) -> (u32, u32) { - let extra_w = src_width.saturating_sub(width); - let extra_h = src_height.saturating_sub(height); - - let x = match gravity { - Gravity::West | Gravity::NorthWest | Gravity::SouthWest => 0, - Gravity::East | Gravity::NorthEast | Gravity::SouthEast => extra_w, - _ => extra_w / 2, - }; - - let y = match gravity { - Gravity::North | Gravity::NorthEast | Gravity::NorthWest => 0, - Gravity::South | Gravity::SouthEast | Gravity::SouthWest => extra_h, - _ => extra_h / 2, - }; - - (x, y) -} - -/// Resolves target resize dimensions, filling in zero values according to imgproxy rules. -pub fn resolve_resize_dimensions( - resize: &Resize, - src_width: u32, - src_height: u32, -) -> Result<(u32, u32), TransformError> { - let mut width = resize.width; - let mut height = resize.height; - - if width == 0 && height == 0 { - return Err(TransformError::invalid( - "resize", - "resize requires at least one non-zero dimension", - )); - } - - let aspect = src_width as f64 / src_height as f64; - - if resize.resizing_type == "force" { - if width == 0 { - width = src_width; - } - if height == 0 { - height = src_height; - } - } else { - if width == 0 { - width = ((height as f64) * aspect).round() as u32; - } - if height == 0 { - height = ((width as f64) / aspect).round() as u32; - } - } - - if width == 0 || height == 0 { - return Err(TransformError::invalid("resize", "resize resolved to zero dimension")); - } - - Ok((width, height)) -} - -/// Applies resize operation based on the resize type. -/// Caps scaling so nothing is enlarged, following imgproxy: the resizing type -/// settles the scale first, then the cap divides every axis by the largest -/// scale when that exceeds 1. The axis that would have been enlarged lands -/// exactly at 1 and the others keep their relative proportion — which is not -/// the same as refusing the whole operation, because a fit whose box is taller -/// than the source still has to shrink the width. -fn cap_enlargement(scales: &mut [f64; 2], enlarge: bool) { - if enlarge { - return; - } - let largest = scales[0].max(scales[1]); - if largest > 1.0 { - scales[0] /= largest; - scales[1] /= largest; - } -} - -pub fn apply_resize( - img: VipsImage, - resize: &Resize, - gravity: &Option, - resizing_algorithm: Option<&str>, - enlarge: bool, -) -> Result { - let src_width = img.get_width() as u32; - let src_height = img.get_height() as u32; - let (target_w, target_h) = resolve_resize_dimensions(resize, src_width, src_height)?; - - match resize.resizing_type.as_str() { - "fill" => resize_to_fill( - img, - target_w, - target_h, - gravity.unwrap_or(Gravity::Center), - resizing_algorithm, - enlarge, - ), - "fit" => resize_to_fit(img, target_w, target_h, resizing_algorithm, enlarge), - "force" => resize_to_force(img, target_w, target_h, resizing_algorithm, enlarge), - "auto" => { - let src_is_portrait = super::utils::is_portrait(src_width, src_height); - let target_is_portrait = super::utils::is_portrait(target_w, target_h); - - if src_is_portrait == target_is_portrait { - debug!("Auto resize: orientations match, using fill"); - resize_to_fill( - img, - target_w, - target_h, - gravity.unwrap_or(Gravity::Center), - resizing_algorithm, - enlarge, - ) - } else { - debug!("Auto resize: orientations differ, using fit"); - resize_to_fit(img, target_w, target_h, resizing_algorithm, enlarge) - } - } - _ => Err(TransformError::invalid( - "resize", - format!("Unknown resize type: {}", resize.resizing_type), - )), - } -} - -/// Resizes an image to fill the target dimensions, cropping if necessary. -fn resize_to_fill( - img: VipsImage, - width: u32, - height: u32, - gravity: Gravity, - resizing_algorithm: Option<&str>, - enlarge: bool, -) -> Result { - let (img_w, img_h) = (img.get_width() as u32, img.get_height() as u32); - let aspect_ratio = img_w as f32 / img_h as f32; - let target_aspect_ratio = width as f32 / height as f32; - - // Cover the box: scale by whichever axis needs the most. - let cover = if aspect_ratio > target_aspect_ratio { - height as f64 / img_h as f64 - } else { - width as f64 / img_w as f64 - }; - let mut scales = [cover; 2]; - cap_enlargement(&mut scales, enlarge); - - let resized_img = if (scales[0] - 1.0).abs() < SCALE_EPSILON { - img - } else { - // Bump the scale slightly so kernels that round down still cover the target. - resize_with_algorithm( - &img, - scales[0] * (1.0 + SCALE_EPSILON), - None, - resizing_algorithm, - "Error resizing for fill", - )? - }; - - let resized_w = resized_img.get_width() as u32; - let resized_h = resized_img.get_height() as u32; - - // With enlargement capped the image can be smaller than the requested box, - // so the window is what is actually available. Cropping to the full box - // would ask libvips for pixels that do not exist. - let crop_w = width.min(resized_w); - let crop_h = height.min(resized_h); - let extra_w = resized_w - crop_w; - let extra_h = resized_h - crop_h; - - let crop_x = match gravity { - Gravity::West | Gravity::NorthWest | Gravity::SouthWest => 0, - Gravity::East | Gravity::NorthEast | Gravity::SouthEast => extra_w, - _ => extra_w / 2, - }; - - let crop_y = match gravity { - Gravity::North | Gravity::NorthEast | Gravity::NorthWest => 0, - Gravity::South | Gravity::SouthEast | Gravity::SouthWest => extra_h, - _ => extra_h / 2, - }; - - ops::extract_area(&resized_img, crop_x as i32, crop_y as i32, crop_w as i32, crop_h as i32) - .map_err(vips("Error cropping after fill resize")) -} - -/// Resizes an image to the exact target dimensions, allowing aspect ratio changes. -fn resize_to_force( - img: VipsImage, - width: u32, - height: u32, - resizing_algorithm: Option<&str>, - enlarge: bool, -) -> Result { - let (src_w, src_h) = (img.get_width() as f64, img.get_height() as f64); - let mut scales = [width as f64 / src_w, height as f64 / src_h]; - cap_enlargement(&mut scales, enlarge); - - if (scales[0] - 1.0).abs() < SCALE_EPSILON && (scales[1] - 1.0).abs() < SCALE_EPSILON { - return Ok(img); - } - resize_with_algorithm( - &img, - scales[0], - Some(scales[1]), - resizing_algorithm, - "Error force resizing", - ) -} - -/// Resizes an image to fit within the target dimensions while maintaining aspect ratio. -fn resize_to_fit( - img: VipsImage, - width: u32, - height: u32, - resizing_algorithm: Option<&str>, - enlarge: bool, -) -> Result { - let (img_w, img_h) = (img.get_width() as u32, img.get_height() as u32); - let aspect_ratio = img_w as f32 / img_h as f32; - - let (target_w, target_h) = if height == 0 { - (width, (width as f32 / aspect_ratio).round() as u32) - } else if width == 0 { - ((height as f32 * aspect_ratio).round() as u32, height) - } else { - (width, height) - }; - - debug!("Resizing to fit from {}x{} to {}x{}", img_w, img_h, target_w, target_h); - let scale_w = target_w as f64 / img_w as f64; - let scale_h = target_h as f64 / img_h as f64; - let mut scales = [scale_w.min(scale_h); 2]; - cap_enlargement(&mut scales, enlarge); - - if (scales[0] - 1.0).abs() < SCALE_EPSILON { - return Ok(img); - } - - resize_with_algorithm(&img, scales[0], None, resizing_algorithm, "Error fitting resize") -} - -/// Extends an image to the target dimensions with background color. -pub fn extend_image( - img: VipsImage, - width: u32, - height: u32, - gravity: &Option, - background: &Option<[u8; 4]>, -) -> Result { - let bg_color = background.unwrap_or([0, 0, 0, 0]); - let src_w = img.get_width() as u32; - let src_h = img.get_height() as u32; - if width < src_w || height < src_h { - return Err(TransformError::invalid( - "extend", - format!( - "extend target {}x{} must be at least source {}x{}", - width, height, src_w, src_h - ), - )); - } - - let gravity = gravity.unwrap_or(Gravity::Center); - - let (x, y) = match gravity { - Gravity::Center => ((width - src_w) / 2, (height - src_h) / 2), - Gravity::North => ((width - src_w) / 2, 0), - Gravity::South => ((width - src_w) / 2, height - src_h), - Gravity::West => (0, (height - src_h) / 2), - Gravity::East => (width - src_w, (height - src_h) / 2), - Gravity::NorthEast => (width - src_w, 0), - Gravity::NorthWest => (0, 0), - Gravity::SouthEast => (width - src_w, height - src_h), - Gravity::SouthWest => (0, height - src_h), - }; - - let options = ops::EmbedOptions { - extend: ops::Extend::Background, - background: bg_color_for_bands(bg_color, img.get_bands()), - }; - ops::embed_with_opts(&img, x as i32, y as i32, width as i32, height as i32, &options) - .map_err(vips("Error extending image")) -} - -/// Applies padding to an image. -pub fn apply_padding( - img: VipsImage, - top: u32, - right: u32, - bottom: u32, - left: u32, - background: &Option<[u8; 4]>, -) -> Result { - // Padding arrives from the URL as an unbounded u32, so the canvas is summed - // in i64. Doing it in i32 wrapped: a value above i32::MAX turned negative, - // which either produced a canvas smaller than the source — silently - // returning a cropped image with a 200 — or panicked in a debug build. - let width = i64::from(img.get_width()) + i64::from(left) + i64::from(right); - let height = i64::from(img.get_height()) + i64::from(top) + i64::from(bottom); - - if width > VIPS_MAX_COORD || height > VIPS_MAX_COORD { - return Err(TransformError::invalid( - "padding", - format!("padded canvas {width}x{height} exceeds the maximum of {VIPS_MAX_COORD} pixels per side"), - )); - } - - // Both offsets are bounded by the canvas checked above, so these fit. - let (x, y) = (left as i32, top as i32); - let bg_color = background.unwrap_or([0, 0, 0, 0]); - let options = ops::EmbedOptions { - extend: ops::Extend::Background, - background: bg_color_for_bands(bg_color, img.get_bands()), - }; - - ops::embed_with_opts(&img, x, y, width as i32, height as i32, &options).map_err(vips("Error applying padding")) -} - -/// Applies rotation to an image. -pub fn apply_rotation(img: VipsImage, rotation: u16) -> Result { - match rotation { - 0 => Ok(img), - 90 => ops::rot(&img, ops::Angle::D90).map_err(vips("Error rotating 90")), - 180 => ops::rot(&img, ops::Angle::D180).map_err(vips("Error rotating 180")), - 270 => ops::rot(&img, ops::Angle::D270).map_err(vips("Error rotating 270")), - _ => Err(TransformError::invalid( - "rotation", - format!("Unsupported rotation angle: {rotation}"), - )), - } -} - -/// Applies horizontal and/or vertical flips to an image. -pub fn apply_flip(mut img: VipsImage, flip: Flip) -> Result { - if flip.horizontal { - img = ops::flip(&img, ops::Direction::Horizontal).map_err(vips("Error flipping horizontally"))?; - } - if flip.vertical { - img = ops::flip(&img, ops::Direction::Vertical).map_err(vips("Error flipping vertically"))?; - } - Ok(img) -} - -/// Applies blur to an image. -pub fn apply_blur(img: VipsImage, sigma: f32) -> Result { - if !sigma.is_finite() || sigma <= 0.0 { - return Err(TransformError::invalid( - "blur", - "blur sigma must be a finite positive number", - )); - } - ops::gaussblur(&img, sigma as f64).map_err(vips("Error applying blur")) -} - -/// Applies brightness, contrast, and saturation adjustments. -pub fn apply_adjust(img: VipsImage, adjust: Adjust) -> Result { - let mut current = img; - - // The generated libvips bindings in this crate do not expose `linear`, so - // brightness/contrast are parsed for compatibility and saturation is applied - // where libvips exposes a stable operation. - let _ = (adjust.brightness, adjust.contrast); - - if (adjust.saturation - 1.0).abs() > f32::EPSILON { - current = apply_saturation(current, adjust.saturation)?; - } - - Ok(current) -} - -fn apply_saturation(img: VipsImage, saturation: f32) -> Result { - if !saturation.is_finite() || saturation <= 0.0 { - return Err(TransformError::invalid( - "saturation", - "saturation must be a finite positive number", - )); - } - - let bands = img.get_bands(); - if bands != 3 && bands != 4 { - return Ok(img); - } - - let s = saturation as f64; - let inv = 1.0 - s; - let rw = 0.2126; - let gw = 0.7152; - let bw = 0.0722; - - let (width, matrix) = if bands == 4 { - ( - 4, - vec![ - rw * inv + s, - gw * inv, - bw * inv, - 0.0, - rw * inv, - gw * inv + s, - bw * inv, - 0.0, - rw * inv, - gw * inv, - bw * inv + s, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - ], - ) - } else { - ( - 3, - vec![ - rw * inv + s, - gw * inv, - bw * inv, - rw * inv, - gw * inv + s, - bw * inv, - rw * inv, - gw * inv, - bw * inv + s, - ], - ) - }; - - let matrix = VipsImage::image_new_matrix_from_array(width, width, &matrix) - .map_err(vips("Error creating saturation matrix"))?; - ops::recomb(&img, &matrix).map_err(vips("Error applying saturation")) -} - -/// Applies background color to an image (useful for JPEG output). -pub fn apply_background_color(img: VipsImage, _bg_color: [u8; 4]) -> Result { - // Only flatten if the image has an alpha channel (bands == 4 for RGBA or bands == 2 for grayscale+alpha) - let bands = img.get_bands(); - if bands != 4 && bands != 2 { - // No alpha channel, nothing to flatten - return as-is - return Ok(img); - } - - // Use libvips flatten to composite over a solid background, dropping alpha. - // Only RGB is used; input alpha is ignored for the background color itself. - let bg = vec![_bg_color[0] as f64, _bg_color[1] as f64, _bg_color[2] as f64]; - let opts = ops::FlattenOptions { - background: bg, - ..Default::default() - }; - ops::flatten_with_opts(&img, &opts).map_err(vips("Error applying background color")) -} - -/// Applies min-width and min-height constraints to an image. -pub fn apply_min_dimensions( - img: VipsImage, - min_width: Option, - min_height: Option, - resizing_algorithm: Option<&str>, -) -> Result { - let mut current_img = img; - let (img_w, img_h) = (current_img.get_width() as u32, current_img.get_height() as u32); - - let mut scale_w = 1.0; - if let Some(mw) = min_width { - if img_w < mw { - scale_w = mw as f64 / img_w as f64; - } - } - - let mut scale_h = 1.0; - if let Some(mh) = min_height { - if img_h < mh { - scale_h = mh as f64 / img_h as f64; - } - } - - let scale = scale_w.max(scale_h); - if scale > 1.0 { - current_img = resize_with_algorithm( - ¤t_img, - scale, - None, - resizing_algorithm, - "Error applying min dimensions", - )?; - } - - Ok(current_img) -} - -/// Applies zoom to an image. -pub fn apply_zoom(img: VipsImage, zoom: f32, resizing_algorithm: Option<&str>) -> Result { - if !zoom.is_finite() || zoom <= 0.0 { - return Err(TransformError::invalid("zoom", "zoom must be a finite positive number")); - } - resize_with_algorithm(&img, zoom as f64, None, resizing_algorithm, "Error applying zoom") -} - -/// Sharpens an image. -pub fn apply_sharpen(img: VipsImage, sigma: f32) -> Result { - if !sigma.is_finite() || sigma <= 0.0 { - return Err(TransformError::invalid( - "sharpen", - "sharpen sigma must be a finite positive number", - )); - } - let clamped_sigma = sigma.clamp(0.1, 10.0); - let opts = ops::SharpenOptions { - sigma: clamped_sigma as f64, - ..Default::default() - }; - ops::sharpen_with_opts(&img, &opts).map_err(vips("Error applying sharpen")) -} - -/// Pixelates an image. -pub fn apply_pixelate( - img: VipsImage, - amount: u32, - _resizing_algorithm: Option<&str>, -) -> Result { - if amount == 0 { - return Ok(img); - } - let (w, h) = (img.get_width() as u32, img.get_height() as u32); - let target_w = (w / amount).max(1); - let target_h = (h / amount).max(1); - let pixelated = resize_with_algorithm( - &img, - target_w as f64 / w as f64, - Some(target_h as f64 / h as f64), - Some("nearest"), - "Error pixelating (down)", - )?; - resize_with_algorithm( - &pixelated, - w as f64 / pixelated.get_width() as f64, - Some(h as f64 / pixelated.get_height() as f64), - Some("nearest"), - "Error pixelating (up)", - ) -} diff --git a/src/processing/transform/effects.rs b/src/processing/transform/effects.rs new file mode 100644 index 0000000..68cc46a --- /dev/null +++ b/src/processing/transform/effects.rs @@ -0,0 +1,215 @@ +//! Colour and pixel effects: adjustment, blur, sharpen, pixelate, and +//! flattening onto a background. + +use super::{resize_with_algorithm, vips, TransformError}; +use crate::processing::options::Adjust; +use libvips::{ops, VipsImage}; + +/// Applies brightness, contrast, and saturation adjustments. +/// +/// Brightness and contrast go through a single `vips_linear`, which computes +/// `a * in + b` per band. Contrast pivots around mid-grey so it darkens shadows +/// and brightens highlights rather than shifting the whole image, and the +/// brightness offset is folded into the same `b` — the result is contrast +/// applied first, then brightness, in one pass over the pixels. +pub fn apply_adjust(img: VipsImage, adjust: Adjust) -> Result { + let mut current = img; + + if adjust.brightness != 0 || (adjust.contrast - 1.0).abs() > f32::EPSILON { + current = apply_brightness_contrast(current, adjust.brightness, f64::from(adjust.contrast))?; + } + + if (adjust.saturation - 1.0).abs() > f32::EPSILON { + current = apply_saturation(current, adjust.saturation)?; + } + + Ok(current) +} + +/// How many units of the band format make up one 8-bit step. +/// +/// The URL speaks in 8-bit terms — `brightness:64` means a quarter of the way +/// up the range — so a 16-bit source has to have that scaled up, or the same +/// URL would nudge a 16-bit image 256 times less than an 8-bit one. +fn channel_scale(img: &VipsImage) -> f64 { + match img.get_format() { + Ok(ops::BandFormat::Ushort) | Ok(ops::BandFormat::Short) => 257.0, + _ => 1.0, + } +} + +fn apply_brightness_contrast(img: VipsImage, brightness: i16, contrast: f64) -> Result { + let bands = usize::try_from(img.get_bands()).unwrap_or(0); + if bands == 0 { + return Ok(img); + } + + let scale = channel_scale(&img); + let midpoint = 128.0 * scale; + let offset = midpoint * (1.0 - contrast) + f64::from(brightness) * scale; + + let mut multipliers = vec![contrast; bands]; + let mut adders = vec![offset; bands]; + + // Alpha is opacity, not colour: brightening it would fade the image in or + // out instead of lightening it. + if img.image_hasalpha() { + multipliers[bands - 1] = 1.0; + adders[bands - 1] = 0.0; + } + + let format = img.get_format(); + let adjusted = + ops::linear(&img, &mut multipliers, &mut adders).map_err(vips("Error applying brightness and contrast"))?; + + // `linear` promotes to float to hold values that fall outside the input + // range. Casting back clips them and keeps the rest of the pipeline, and + // the encoder, on the format the source arrived in. + match format { + Ok(format) => ops::cast(&adjusted, format).map_err(vips("Error applying brightness and contrast")), + Err(_) => Ok(adjusted), + } +} + +fn apply_saturation(img: VipsImage, saturation: f32) -> Result { + if !saturation.is_finite() || saturation <= 0.0 { + return Err(TransformError::invalid( + "saturation", + "saturation must be a finite positive number", + )); + } + + let bands = img.get_bands(); + if bands != 3 && bands != 4 { + return Ok(img); + } + + let s = f64::from(saturation); + let inv = 1.0 - s; + let rw = 0.2126; + let gw = 0.7152; + let bw = 0.0722; + + let (width, matrix) = if bands == 4 { + ( + 4, + vec![ + rw * inv + s, + gw * inv, + bw * inv, + 0.0, + rw * inv, + gw * inv + s, + bw * inv, + 0.0, + rw * inv, + gw * inv, + bw * inv + s, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + ], + ) + } else { + ( + 3, + vec![ + rw * inv + s, + gw * inv, + bw * inv, + rw * inv, + gw * inv + s, + bw * inv, + rw * inv, + gw * inv, + bw * inv + s, + ], + ) + }; + + let matrix = VipsImage::image_new_matrix_from_array(width, width, &matrix) + .map_err(vips("Error creating saturation matrix"))?; + ops::recomb(&img, &matrix).map_err(vips("Error applying saturation")) +} + +/// Composites an image with alpha over a solid background, dropping alpha. +/// +/// Only RGB is used; the background's own alpha is ignored, because the point +/// of flattening is to produce an image that no longer has any. +pub fn flatten_onto_background(img: VipsImage, bg_color: [u8; 4]) -> Result { + // Nothing to flatten without an alpha channel (4 bands for RGBA, 2 for + // greyscale plus alpha). + let bands = img.get_bands(); + if bands != 4 && bands != 2 { + return Ok(img); + } + + let bg = vec![f64::from(bg_color[0]), f64::from(bg_color[1]), f64::from(bg_color[2])]; + let opts = ops::FlattenOptions { + background: bg, + ..Default::default() + }; + ops::flatten_with_opts(&img, &opts).map_err(vips("Error applying background color")) +} + +/// Applies background color to an image (useful for JPEG output). +pub fn apply_background_color(img: VipsImage, bg_color: [u8; 4]) -> Result { + flatten_onto_background(img, bg_color) +} + +/// Applies blur to an image. +pub fn apply_blur(img: VipsImage, sigma: f32) -> Result { + if !sigma.is_finite() || sigma <= 0.0 { + return Err(TransformError::invalid( + "blur", + "blur sigma must be a finite positive number", + )); + } + ops::gaussblur(&img, f64::from(sigma)).map_err(vips("Error applying blur")) +} + +/// Sharpens an image. +pub fn apply_sharpen(img: VipsImage, sigma: f32) -> Result { + if !sigma.is_finite() || sigma <= 0.0 { + return Err(TransformError::invalid( + "sharpen", + "sharpen sigma must be a finite positive number", + )); + } + let clamped_sigma = sigma.clamp(0.1, 10.0); + let opts = ops::SharpenOptions { + sigma: f64::from(clamped_sigma), + ..Default::default() + }; + ops::sharpen_with_opts(&img, &opts).map_err(vips("Error applying sharpen")) +} + +/// Pixelates an image. +pub fn apply_pixelate(img: VipsImage, amount: u32) -> Result { + if amount <= 1 { + return Ok(img); + } + let (w, h) = (img.get_width().max(0) as u32, img.get_height().max(0) as u32); + if w == 0 || h == 0 { + return Ok(img); + } + + let target_w = (w / amount).max(1); + let target_h = (h / amount).max(1); + let pixelated = resize_with_algorithm( + &img, + f64::from(target_w) / f64::from(w), + Some(f64::from(target_h) / f64::from(h)), + Some("nearest"), + "Error pixelating (down)", + )?; + resize_with_algorithm( + &pixelated, + f64::from(w) / f64::from(pixelated.get_width()), + Some(f64::from(h) / f64::from(pixelated.get_height())), + Some("nearest"), + "Error pixelating (up)", + ) +} diff --git a/src/processing/transform/geometry.rs b/src/processing/transform/geometry.rs new file mode 100644 index 0000000..29d5b6e --- /dev/null +++ b/src/processing/transform/geometry.rs @@ -0,0 +1,263 @@ +//! Positioning and canvas sizing: where a window sits, and how the image is +//! padded out to a larger one. + +use super::{bg_color_for_bands, vips, TransformError, VIPS_MAX_COORD}; +use crate::processing::options::{Crop, Gravity, GravityType}; +use libvips::{ops, VipsImage}; +use tracing::debug; + +/// Rounds to the nearest even integer. +/// +/// imgproxy aligns every computed offset this way so that a crop never lands on +/// an odd boundary, which would shift the chroma planes of a subsampled source +/// by half a pixel and tint the edge. +fn round_to_even(value: f64) -> i64 { + if !value.is_finite() { + return 0; + } + ((value / 2.0).round() * 2.0) as i64 +} + +/// Scales `extent` by `factor` and rounds the result to an even integer. +fn scale_to_even(extent: i64, factor: f64) -> i64 { + round_to_even(extent as f64 * factor) +} + +/// Halves `value`, rounding toward zero and then down to an even number. +fn half_to_even(value: i64) -> i64 { + let halved = value / 2; + halved - (halved % 2) +} + +/// Where an `inner_width` x `inner_height` window sits inside a +/// `width` x `height` canvas, given a gravity. +/// +/// Mirrors imgproxy's `calcPosition`. Offsets are absolute pixels once their +/// magnitude reaches 1 and a fraction of the axis below it; `offset_scale` +/// scales the absolute form so a DPR-aware request nudges by the same visual +/// distance. `allow_overflow` lets the window hang off the canvas, which +/// watermarking needs and cropping must not have. +pub fn calc_position( + width: i64, + height: i64, + inner_width: i64, + inner_height: i64, + gravity: &Gravity, + offset_scale: f64, + allow_overflow: bool, +) -> (i64, i64) { + let (mut left, mut top) = if gravity.kind == GravityType::FocusPoint { + ( + scale_to_even(width, gravity.x) - inner_width / 2, + scale_to_even(height, gravity.y) - inner_height / 2, + ) + } else { + let offset_x = if gravity.x.abs() >= 1.0 { + round_to_even(gravity.x * offset_scale) + } else { + scale_to_even(width, gravity.x) + }; + let offset_y = if gravity.y.abs() >= 1.0 { + round_to_even(gravity.y * offset_scale) + } else { + scale_to_even(height, gravity.y) + }; + + let left = match gravity.kind { + GravityType::West | GravityType::NorthWest | GravityType::SouthWest => offset_x, + GravityType::East | GravityType::NorthEast | GravityType::SouthEast => width - inner_width - offset_x, + _ => half_to_even(width - inner_width + 1) + offset_x, + }; + let top = match gravity.kind { + GravityType::North | GravityType::NorthEast | GravityType::NorthWest => offset_y, + GravityType::South | GravityType::SouthEast | GravityType::SouthWest => height - inner_height - offset_y, + _ => half_to_even(height - inner_height + 1) + offset_y, + }; + + (left, top) + }; + + let (min_x, max_x, min_y, max_y) = if allow_overflow { + (-inner_width + 1, width - 1, -inner_height + 1, height - 1) + } else { + (0, width - inner_width, 0, height - inner_height) + }; + + left = left.clamp(min_x.min(max_x), max_x.max(min_x)); + top = top.clamp(min_y.min(max_y), max_y.max(min_y)); + + (left, top) +} + +/// Crops an image to the region named by a [`Crop`]. +/// +/// A zero extent means "the whole axis", and an extent below 1 is a fraction of +/// the source, so the same URL crops the same proportion whatever size the +/// source turns out to be. +pub fn crop_image(img: VipsImage, crop: &Crop, gravity: &Gravity) -> Result { + let src_width = img.get_width().max(0) as u32; + let src_height = img.get_height().max(0) as u32; + let (requested_width, requested_height) = crop.resolve(src_width, src_height); + + let width = if requested_width == 0 { + src_width + } else { + requested_width.min(src_width) + }; + let height = if requested_height == 0 { + src_height + } else { + requested_height.min(src_height) + }; + + // Nothing to cut: skipping keeps the image's own header rather than paying + // for an extract that returns the same pixels. + if width >= src_width && height >= src_height { + return Ok(img); + } + + // The crop names source pixels, so DPR must not scale its offsets: it runs + // before any DPR-aware scaling has happened. + let (x, y) = calc_position( + i64::from(src_width), + i64::from(src_height), + i64::from(width), + i64::from(height), + gravity, + 1.0, + false, + ); + + ops::extract_area(&img, x as i32, y as i32, width as i32, height as i32).map_err(vips("Error cropping image")) +} + +/// Extends an image onto a larger canvas filled with the background colour. +pub fn extend_image( + img: VipsImage, + width: u32, + height: u32, + gravity: &Gravity, + background: &Option<[u8; 4]>, + offset_scale: f64, +) -> Result { + let src_w = img.get_width().max(0) as u32; + let src_h = img.get_height().max(0) as u32; + + // A canvas no larger than the image extends nothing. imgproxy returns the + // image untouched here rather than treating it as an error, and so does + // every caller of this function. + if width <= src_w && height <= src_h { + return Ok(img); + } + + let width = width.max(src_w); + let height = height.max(src_h); + + // The URL can ask for any width and height it likes, and the canvas is what + // libvips has to allocate. Checking in i64 keeps a value past i32 from + // wrapping negative, which `embed` would either reject or, worse, accept as + // a canvas smaller than the source. + let (canvas_w, canvas_h) = (i64::from(width), i64::from(height)); + if canvas_w > VIPS_MAX_COORD || canvas_h > VIPS_MAX_COORD { + return Err(TransformError::invalid( + "extend", + format!("extended canvas {canvas_w}x{canvas_h} exceeds the maximum of {VIPS_MAX_COORD} pixels per side"), + )); + } + + let (x, y) = calc_position( + canvas_w, + canvas_h, + i64::from(src_w), + i64::from(src_h), + gravity, + offset_scale, + false, + ); + + let bg_color = background.unwrap_or([0, 0, 0, 0]); + let options = ops::EmbedOptions { + extend: ops::Extend::Background, + background: bg_color_for_bands(bg_color, img.get_bands()), + }; + ops::embed_with_opts(&img, x as i32, y as i32, canvas_w as i32, canvas_h as i32, &options) + .map_err(vips("Error extending image")) +} + +/// Extends an image out to the aspect ratio of `target_width:target_height`, +/// growing whichever axis is short and leaving the other alone. +/// +/// This is `extend_aspect_ratio`: the result keeps every source pixel at its +/// resized size and gains background on one axis only, where plain `extend` +/// pads out to the requested pixel dimensions. +pub fn extend_to_aspect_ratio( + img: VipsImage, + target_width: u32, + target_height: u32, + gravity: &Gravity, + background: &Option<[u8; 4]>, + offset_scale: f64, +) -> Result { + if target_width == 0 || target_height == 0 { + return Ok(img); + } + + let src_w = img.get_width().max(0) as u32; + let src_h = img.get_height().max(0) as u32; + if src_w == 0 || src_h == 0 { + return Ok(img); + } + + let target_ratio = f64::from(target_width) / f64::from(target_height); + let source_ratio = f64::from(src_w) / f64::from(src_h); + + let (width, height) = if target_ratio > source_ratio { + // The requested shape is wider than what we have: grow the width. + (((f64::from(src_h) * target_ratio).round() as u32).max(src_w), src_h) + } else if target_ratio < source_ratio { + (src_w, ((f64::from(src_w) / target_ratio).round() as u32).max(src_h)) + } else { + return Ok(img); + }; + + debug!( + "Extending {}x{} to aspect ratio {}:{} -> {}x{}", + src_w, src_h, target_width, target_height, width, height + ); + + extend_image(img, width, height, gravity, background, offset_scale) +} + +/// Applies padding to an image. +pub fn apply_padding( + img: VipsImage, + top: u32, + right: u32, + bottom: u32, + left: u32, + background: &Option<[u8; 4]>, +) -> Result { + // Padding arrives from the URL as an unbounded u32, so the canvas is summed + // in i64. Doing it in i32 wrapped: a value above i32::MAX turned negative, + // which either produced a canvas smaller than the source — silently + // returning a cropped image with a 200 — or panicked in a debug build. + let width = i64::from(img.get_width()) + i64::from(left) + i64::from(right); + let height = i64::from(img.get_height()) + i64::from(top) + i64::from(bottom); + + if width > VIPS_MAX_COORD || height > VIPS_MAX_COORD { + return Err(TransformError::invalid( + "padding", + format!("padded canvas {width}x{height} exceeds the maximum of {VIPS_MAX_COORD} pixels per side"), + )); + } + + // Both offsets are bounded by the canvas checked above, so these fit. + let (x, y) = (left as i32, top as i32); + let bg_color = background.unwrap_or([0, 0, 0, 0]); + let options = ops::EmbedOptions { + extend: ops::Extend::Background, + background: bg_color_for_bands(bg_color, img.get_bands()), + }; + + ops::embed_with_opts(&img, x, y, width as i32, height as i32, &options).map_err(vips("Error applying padding")) +} diff --git a/src/processing/transform/mod.rs b/src/processing/transform/mod.rs new file mode 100644 index 0000000..b218f92 --- /dev/null +++ b/src/processing/transform/mod.rs @@ -0,0 +1,128 @@ +//! Pixel transformations. +//! +//! Each stage of the pipeline lives in its own module: [`geometry`] positions +//! and sizes the canvas, [`resize`] scales, [`effects`] changes colour, and +//! [`orientation`] and [`trim`] handle the two operations that depend on what +//! the source itself carries. + +pub mod effects; +pub mod geometry; +pub mod orientation; +pub mod resize; +pub mod trim; + +use libvips::{ops, VipsImage}; +use thiserror::Error; + +pub use effects::{ + apply_adjust, apply_background_color, apply_blur, apply_pixelate, apply_sharpen, flatten_onto_background, +}; +pub use geometry::{apply_padding, calc_position, crop_image, extend_image, extend_to_aspect_ratio}; +pub use orientation::{apply_exif_orientation, apply_exif_rotation, apply_flip, apply_rotation}; +pub use resize::{apply_min_dimensions, apply_resize, apply_zoom, resolve_resize_dimensions}; +pub use trim::apply_trim; + +/// Scales below this differ from 1 by less than a pixel on any plausible image, +/// so the resize is skipped rather than run for nothing. +pub(crate) const SCALE_EPSILON: f64 = 1e-6; + +/// Largest coordinate libvips accepts for `embed`; anything beyond it is +/// rejected by the operation itself. +pub(crate) const VIPS_MAX_COORD: i64 = 1_000_000_000; + +/// Errors produced while transforming an image. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum TransformError { + #[error("{operation}: {source}")] + Vips { + operation: &'static str, + #[source] + source: libvips::error::Error, + }, + #[error("{message}")] + InvalidArgument { operation: &'static str, message: String }, +} + +impl TransformError { + pub(crate) fn invalid(operation: &'static str, message: impl Into) -> Self { + Self::InvalidArgument { + operation, + message: message.into(), + } + } +} + +pub(crate) fn vips(operation: &'static str) -> impl FnOnce(libvips::error::Error) -> TransformError { + move |source| TransformError::Vips { operation, source } +} + +/// Converts a resizing algorithm string to a libvips Kernel enum. +pub(crate) fn get_resize_kernel(algorithm: Option<&str>) -> ops::Kernel { + match algorithm.unwrap_or("lanczos3") { + "nearest" => ops::Kernel::Nearest, + "linear" => ops::Kernel::Linear, + "cubic" => ops::Kernel::Cubic, + "lanczos2" => ops::Kernel::Lanczos2, + "lanczos3" => ops::Kernel::Lanczos3, + _ => ops::Kernel::Lanczos3, // Default to lanczos3 + } +} + +/// Expands an RGBA background into the component vector a given band count +/// needs, collapsing to luminance for greyscale. +pub(crate) fn bg_color_for_bands(bg_color: [u8; 4], bands: i32) -> Vec { + let luma = (0.299 * bg_color[0] as f64 + 0.587 * bg_color[1] as f64 + 0.114 * bg_color[2] as f64).round(); + match bands { + 4 => vec![ + bg_color[0] as f64, + bg_color[1] as f64, + bg_color[2] as f64, + bg_color[3] as f64, + ], + 3 => vec![bg_color[0] as f64, bg_color[1] as f64, bg_color[2] as f64], + 2 => vec![luma, bg_color[3] as f64], + 1 => vec![luma], + _ => vec![bg_color[0] as f64, bg_color[1] as f64, bg_color[2] as f64], + } +} + +/// Helper to resize using the requested algorithm, defaulting to lanczos3. +/// +/// Images carrying alpha are premultiplied for the duration of the scale. +/// libvips is explicit that `vips_resize` does not do this itself — "if your +/// image has an alpha channel, you should use vips_premultiply() on it first" — +/// and without it the kernel averages the colour of fully transparent pixels +/// into visible ones. Downscaling white-on-transparent that way drags the edge +/// toward whatever colour happens to sit in the invisible pixels, which shows up +/// as a dark halo around logos and cutouts once the result is composited. +pub fn resize_with_algorithm( + img: &VipsImage, + hscale: f64, + vscale: Option, + resizing_algorithm: Option<&str>, + error_context: &'static str, +) -> Result { + let options = ops::ResizeOptions { + kernel: get_resize_kernel(resizing_algorithm), + vscale: vscale.unwrap_or(hscale), + ..Default::default() + }; + + if !img.image_hasalpha() { + return ops::resize_with_opts(img, hscale, &options).map_err(vips(error_context)); + } + + // A source whose format vips cannot report is not one to guess at; fall + // back to the plain resize rather than casting to something invented. + let Ok(source_format) = img.get_format() else { + return ops::resize_with_opts(img, hscale, &options).map_err(vips(error_context)); + }; + let premultiplied = ops::premultiply(img).map_err(vips(error_context))?; + let resized = ops::resize_with_opts(&premultiplied, hscale, &options).map_err(vips(error_context))?; + let restored = ops::unpremultiply(&resized).map_err(vips(error_context))?; + + // premultiply/unpremultiply work in float; without casting back, every + // later step and the encoder would see a float image. + ops::cast(&restored, source_format).map_err(vips(error_context)) +} diff --git a/src/processing/transform/orientation.rs b/src/processing/transform/orientation.rs new file mode 100644 index 0000000..0f77c9d --- /dev/null +++ b/src/processing/transform/orientation.rs @@ -0,0 +1,68 @@ +//! Rotation and flipping, both the EXIF-driven kind and the kind the URL asks +//! for explicitly. + +use super::{vips, TransformError}; +use crate::processing::options::Flip; +use crate::utils::read_exif_orientation; +use libvips::{ops, VipsImage}; +use tracing::debug; + +/// Applies EXIF rotation to an image based on orientation data. +pub fn apply_exif_rotation(image_bytes: &[u8], mut img: VipsImage) -> Result { + if let Some(orientation) = read_exif_orientation(image_bytes) { + debug!("Found EXIF orientation: {:?}", orientation); + img = apply_exif_orientation(img, orientation)?; + } + Ok(img) +} + +pub fn apply_exif_orientation(mut img: VipsImage, orientation: u32) -> Result { + match orientation { + 2 => img = ops::flip(&img, ops::Direction::Horizontal).map_err(vips("Error flipping horizontally"))?, + 3 => img = ops::rot(&img, ops::Angle::D180).map_err(vips("Error rotating 180"))?, + 4 => img = ops::flip(&img, ops::Direction::Vertical).map_err(vips("Error flipping vertically"))?, + 5 => { + img = ops::flip( + &ops::rot(&img, ops::Angle::D90).map_err(vips("Error rotating 90"))?, + ops::Direction::Horizontal, + ) + .map_err(vips("Error flipping after rotate"))? + } + 6 => img = ops::rot(&img, ops::Angle::D90).map_err(vips("Error rotating 90"))?, + 7 => { + img = ops::flip( + &ops::rot(&img, ops::Angle::D270).map_err(vips("Error rotating 270"))?, + ops::Direction::Horizontal, + ) + .map_err(vips("Error flipping after rotate"))? + } + 8 => img = ops::rot(&img, ops::Angle::D270).map_err(vips("Error rotating 270"))?, + _ => {} + } + Ok(img) +} + +/// Applies rotation to an image. +pub fn apply_rotation(img: VipsImage, rotation: u16) -> Result { + match rotation { + 0 => Ok(img), + 90 => ops::rot(&img, ops::Angle::D90).map_err(vips("Error rotating 90")), + 180 => ops::rot(&img, ops::Angle::D180).map_err(vips("Error rotating 180")), + 270 => ops::rot(&img, ops::Angle::D270).map_err(vips("Error rotating 270")), + _ => Err(TransformError::invalid( + "rotation", + format!("Unsupported rotation angle: {rotation}"), + )), + } +} + +/// Applies horizontal and/or vertical flips to an image. +pub fn apply_flip(mut img: VipsImage, flip: Flip) -> Result { + if flip.horizontal { + img = ops::flip(&img, ops::Direction::Horizontal).map_err(vips("Error flipping horizontally"))?; + } + if flip.vertical { + img = ops::flip(&img, ops::Direction::Vertical).map_err(vips("Error flipping vertically"))?; + } + Ok(img) +} diff --git a/src/processing/transform/resize.rs b/src/processing/transform/resize.rs new file mode 100644 index 0000000..346fb5c --- /dev/null +++ b/src/processing/transform/resize.rs @@ -0,0 +1,314 @@ +//! Scaling: the resizing types, the enlargement cap, and the two scale-only +//! options that run after them. + +use super::geometry::calc_position; +use super::{resize_with_algorithm, vips, TransformError, SCALE_EPSILON}; +use crate::processing::options::{Gravity, Resize, ResizingType, Zoom}; +use crate::processing::utils::is_portrait; +use libvips::{ops, VipsImage}; +use tracing::debug; + +/// Resolves target resize dimensions, filling in zero values according to imgproxy rules. +pub fn resolve_resize_dimensions( + resize: &Resize, + src_width: u32, + src_height: u32, +) -> Result<(u32, u32), TransformError> { + let mut width = resize.width; + let mut height = resize.height; + + if width == 0 && height == 0 { + return Err(TransformError::invalid( + "resize", + "resize requires at least one non-zero dimension", + )); + } + + if src_width == 0 || src_height == 0 { + return Err(TransformError::invalid("resize", "source image has a zero dimension")); + } + + let aspect = f64::from(src_width) / f64::from(src_height); + + if resize.resizing_type.fills_zero_axis_from_source() { + if width == 0 { + width = src_width; + } + if height == 0 { + height = src_height; + } + } else { + if width == 0 { + width = (f64::from(height) * aspect).round() as u32; + } + if height == 0 { + height = (f64::from(width) / aspect).round() as u32; + } + } + + if width == 0 || height == 0 { + return Err(TransformError::invalid("resize", "resize resolved to zero dimension")); + } + + Ok((width, height)) +} + +/// Caps scaling so nothing is enlarged, following imgproxy: the resizing type +/// settles the scale first, then the cap divides every axis by the largest +/// scale when that exceeds 1. The axis that would have been enlarged lands +/// exactly at 1 and the others keep their relative proportion — which is not +/// the same as refusing the whole operation, because a fit whose box is taller +/// than the source still has to shrink the width. +fn cap_enlargement(scales: &mut [f64; 2], enlarge: bool) { + if enlarge { + return; + } + let largest = scales[0].max(scales[1]); + if largest > 1.0 { + scales[0] /= largest; + scales[1] /= largest; + } +} + +/// Applies resize operation based on the resize type. +pub fn apply_resize( + img: VipsImage, + resize: &Resize, + gravity: &Gravity, + resizing_algorithm: Option<&str>, + enlarge: bool, + offset_scale: f64, +) -> Result { + let src_width = img.get_width().max(0) as u32; + let src_height = img.get_height().max(0) as u32; + let (target_w, target_h) = resolve_resize_dimensions(resize, src_width, src_height)?; + + let resizing_type = match resize.resizing_type { + ResizingType::Auto => { + let src_is_portrait = is_portrait(src_width, src_height); + let target_is_portrait = is_portrait(target_w, target_h); + if src_is_portrait == target_is_portrait { + debug!("Auto resize: orientations match, using fill"); + ResizingType::Fill + } else { + debug!("Auto resize: orientations differ, using fit"); + ResizingType::Fit + } + } + other => other, + }; + + match resizing_type { + ResizingType::Fill | ResizingType::FillDown => resize_to_fill( + img, + target_w, + target_h, + gravity, + resizing_algorithm, + enlarge, + resizing_type == ResizingType::FillDown, + offset_scale, + ), + ResizingType::Fit => resize_to_fit(img, target_w, target_h, resizing_algorithm, enlarge), + ResizingType::Force => resize_to_force(img, target_w, target_h, resizing_algorithm, enlarge), + // `auto` was rewritten above; matching it here would be unreachable. + ResizingType::Auto => unreachable!("auto resizing is resolved before dispatch"), + } +} + +/// The window a fill crops to once the image has been scaled. +/// +/// Plain `fill` always crops to the requested box, clamped to what the scaled +/// image actually offers. `fill-down` instead keeps the requested *aspect +/// ratio* when the scaled image came out smaller than the box, so the result is +/// the largest crop of that shape the image can supply rather than a smaller +/// box padded out — which is the whole difference between the two types. +fn fill_window( + scaled_w: u32, + scaled_h: u32, + target_w: u32, + target_h: u32, + fill_down: bool, + enlarge: bool, +) -> (u32, u32) { + if !fill_down || enlarge || scaled_w == 0 || scaled_h == 0 || target_w == 0 || target_h == 0 { + return (target_w.min(scaled_w), target_h.min(scaled_h)); + } + + let diff_w = f64::from(target_w) / f64::from(scaled_w); + let diff_h = f64::from(target_h) / f64::from(scaled_h); + let aspect = f64::from(target_w) / f64::from(target_h); + + let (window_w, window_h) = if diff_w > diff_h && diff_w > 1.0 { + (scaled_w, ((f64::from(scaled_w) / aspect).round() as u32).max(1)) + } else if diff_h > diff_w && diff_h > 1.0 { + (((f64::from(scaled_h) * aspect).round() as u32).max(1), scaled_h) + } else { + (target_w, target_h) + }; + + (window_w.min(scaled_w), window_h.min(scaled_h)) +} + +/// Resizes an image to cover the target dimensions, cropping the overhang. +#[allow(clippy::too_many_arguments)] +fn resize_to_fill( + img: VipsImage, + width: u32, + height: u32, + gravity: &Gravity, + resizing_algorithm: Option<&str>, + enlarge: bool, + fill_down: bool, + offset_scale: f64, +) -> Result { + let (img_w, img_h) = (img.get_width().max(0) as u32, img.get_height().max(0) as u32); + let aspect_ratio = f64::from(img_w) / f64::from(img_h); + let target_aspect_ratio = f64::from(width) / f64::from(height); + + // Cover the box: scale by whichever axis needs the most. + let cover = if aspect_ratio > target_aspect_ratio { + f64::from(height) / f64::from(img_h) + } else { + f64::from(width) / f64::from(img_w) + }; + let mut scales = [cover; 2]; + cap_enlargement(&mut scales, enlarge); + + let resized_img = if (scales[0] - 1.0).abs() < SCALE_EPSILON { + img + } else { + // Bump the scale slightly so kernels that round down still cover the target. + resize_with_algorithm( + &img, + scales[0] * (1.0 + SCALE_EPSILON), + None, + resizing_algorithm, + "Error resizing for fill", + )? + }; + + let resized_w = resized_img.get_width().max(0) as u32; + let resized_h = resized_img.get_height().max(0) as u32; + + let (crop_w, crop_h) = fill_window(resized_w, resized_h, width, height, fill_down, enlarge); + + if crop_w >= resized_w && crop_h >= resized_h { + return Ok(resized_img); + } + + // Cropping happens after the scale, so an absolute gravity offset is + // measured against the scaled image — and DPR is what scaled it. Folding + // DPR into the resize target is precisely why the offset has to grow with + // it: at `dpr:2` the result is twice the size, so a 10px nudge that stayed + // 10px would move the window half as far as it did at 1x. imgproxy passes + // its DPR scale here for the same reason. + let (crop_x, crop_y) = calc_position( + i64::from(resized_w), + i64::from(resized_h), + i64::from(crop_w), + i64::from(crop_h), + gravity, + offset_scale, + false, + ); + + ops::extract_area(&resized_img, crop_x as i32, crop_y as i32, crop_w as i32, crop_h as i32) + .map_err(vips("Error cropping after fill resize")) +} + +/// Resizes an image to the exact target dimensions, allowing aspect ratio changes. +fn resize_to_force( + img: VipsImage, + width: u32, + height: u32, + resizing_algorithm: Option<&str>, + enlarge: bool, +) -> Result { + let (src_w, src_h) = (f64::from(img.get_width()), f64::from(img.get_height())); + let mut scales = [f64::from(width) / src_w, f64::from(height) / src_h]; + cap_enlargement(&mut scales, enlarge); + + if (scales[0] - 1.0).abs() < SCALE_EPSILON && (scales[1] - 1.0).abs() < SCALE_EPSILON { + return Ok(img); + } + resize_with_algorithm( + &img, + scales[0], + Some(scales[1]), + resizing_algorithm, + "Error force resizing", + ) +} + +/// Resizes an image to fit within the target dimensions while maintaining aspect ratio. +fn resize_to_fit( + img: VipsImage, + width: u32, + height: u32, + resizing_algorithm: Option<&str>, + enlarge: bool, +) -> Result { + let (img_w, img_h) = (img.get_width().max(0) as u32, img.get_height().max(0) as u32); + + debug!("Resizing to fit from {}x{} to {}x{}", img_w, img_h, width, height); + let scale_w = f64::from(width) / f64::from(img_w); + let scale_h = f64::from(height) / f64::from(img_h); + let mut scales = [scale_w.min(scale_h); 2]; + cap_enlargement(&mut scales, enlarge); + + if (scales[0] - 1.0).abs() < SCALE_EPSILON { + return Ok(img); + } + + resize_with_algorithm(&img, scales[0], None, resizing_algorithm, "Error fitting resize") +} + +/// Applies min-width and min-height constraints to an image. +pub fn apply_min_dimensions( + img: VipsImage, + min_width: Option, + min_height: Option, + resizing_algorithm: Option<&str>, +) -> Result { + let (img_w, img_h) = (img.get_width().max(0) as u32, img.get_height().max(0) as u32); + if img_w == 0 || img_h == 0 { + return Ok(img); + } + + let scale_w = min_width + .filter(|min| img_w < *min) + .map(|min| f64::from(min) / f64::from(img_w)) + .unwrap_or(1.0); + let scale_h = min_height + .filter(|min| img_h < *min) + .map(|min| f64::from(min) / f64::from(img_h)) + .unwrap_or(1.0); + + let scale = scale_w.max(scale_h); + if scale <= 1.0 { + return Ok(img); + } + + resize_with_algorithm(&img, scale, None, resizing_algorithm, "Error applying min dimensions") +} + +/// Applies zoom to an image. +pub fn apply_zoom(img: VipsImage, zoom: Zoom, resizing_algorithm: Option<&str>) -> Result { + if !zoom.x.is_finite() || !zoom.y.is_finite() || zoom.x <= 0.0 || zoom.y <= 0.0 { + return Err(TransformError::invalid( + "zoom", + "zoom factors must be finite positive numbers", + )); + } + if zoom.is_identity() { + return Ok(img); + } + resize_with_algorithm( + &img, + f64::from(zoom.x), + Some(f64::from(zoom.y)), + resizing_algorithm, + "Error applying zoom", + ) +} diff --git a/src/processing/transform/trim.rs b/src/processing/transform/trim.rs new file mode 100644 index 0000000..b2d6652 --- /dev/null +++ b/src/processing/transform/trim.rs @@ -0,0 +1,97 @@ +//! Removing a uniform border. + +use super::{vips, TransformError}; +use crate::processing::options::Trim; +use libvips::{ops, VipsImage}; +use tracing::debug; + +/// How many components `find_trim` expects in a background colour: one per +/// band, less the alpha if there is one. libvips accepts a single value or +/// exactly that many, and rejects anything else — three components against a +/// CMYK image fails with "vector must have 1 or 4 elements". +fn background_components(img: &VipsImage) -> usize { + let bands = usize::try_from(img.get_bands()).unwrap_or(1).max(1); + if img.image_hasalpha() { + bands.saturating_sub(1).max(1) + } else { + bands + } +} + +/// Reads the top-left pixel, to use as the background when the request does not +/// name one. imgproxy works this out from the image the same way; libvips on its +/// own would assume white, which never trims a dark border. +/// +/// Averaging a one-pixel band reads its value whatever the band format, so this +/// works on 16-bit sources as well as 8-bit — interpreting raw memory would +/// have meant knowing the layout of each format. +fn corner_pixel(img: &VipsImage, components: usize) -> Option> { + let corner = ops::extract_area(img, 0, 0, 1, 1).ok()?; + (0..components) + .map(|band| { + let band = ops::extract_band(&corner, i32::try_from(band).ok()?).ok()?; + ops::avg(&band).ok() + }) + .collect() +} + +/// Trims a uniform border. +/// +/// Note for callers: the trimmed size is not knowable in advance, which is why +/// scale-on-load steps aside when this is in play — there is no way to choose a +/// decode scale against an unknown result. +pub fn apply_trim(img: VipsImage, trim: &Trim) -> Result { + let components = background_components(&img); + let background = match trim.color { + // An explicit colour arrives as sRGB, which only lines up with a + // three-component image. Greyscale takes its luminance; anything else — + // CMYK, say — has no meaningful conversion, and guessing would trim the + // wrong thing silently. + Some(color) if components == 3 => vec![f64::from(color[0]), f64::from(color[1]), f64::from(color[2])], + Some(color) if components == 1 => { + vec![0.299 * f64::from(color[0]) + 0.587 * f64::from(color[1]) + 0.114 * f64::from(color[2])] + } + Some(_) => { + return Err(TransformError::invalid( + "trim", + format!( + "trim colour cannot be applied to a {components}-component image; omit it to detect the background instead" + ), + )) + } + None => corner_pixel(&img, components).unwrap_or_else(|| vec![255.0; components]), + }; + + let options = ops::FindTrimOptions { + threshold: trim.threshold, + background, + line_art: false, + }; + let (left, top, width, height) = ops::find_trim_with_opts(&img, &options).map_err(vips("Error finding trim"))?; + + // An image that is entirely background has nothing to keep. Returning it + // untouched beats handing back an empty or one-pixel image. + if width <= 0 || height <= 0 { + debug!("Trim found no content to keep; leaving the image alone"); + return Ok(img); + } + + let (src_width, src_height) = (img.get_width(), img.get_height()); + let (mut left, mut top, mut width, mut height) = (left, top, width, height); + + // "Equal" means the same amount comes off both sides, so the subject keeps + // its position rather than shifting toward whichever border was thicker. + if trim.equal_hor { + let margin = left.min(src_width - (left + width)); + left = margin; + width = src_width - 2 * margin; + } + if trim.equal_ver { + let margin = top.min(src_height - (top + height)); + top = margin; + height = src_height - 2 * margin; + } + + debug!("Trimming to {}x{} at ({}, {})", width, height, left, top); + ops::extract_area(&img, left, top, width, height).map_err(vips("Error trimming image")) +} diff --git a/src/processing/utils.rs b/src/processing/utils.rs index 63b1e45..af45517 100644 --- a/src/processing/utils.rs +++ b/src/processing/utils.rs @@ -43,15 +43,11 @@ pub fn parse_hex_color(hex: &str) -> Result<[u8; 4], ColorParseError> { /// Parses a string into a boolean value. /// -/// # Arguments -/// -/// * `s` - The string to parse ("1", "true" for true, anything else for false). -/// -/// # Returns -/// -/// `true` if the string is "1" or "true" (case-sensitive), `false` otherwise. +/// Accepts the set imgproxy accepts — `1`, `t`, `T`, `true`, `TRUE`, `True` — +/// so a URL written against imgproxy's documentation reads the same here. +/// Anything else, including an empty argument, is false. pub fn parse_boolean(s: &str) -> bool { - matches!(s, "1" | "true") + matches!(s, "1" | "t" | "T" | "true" | "TRUE" | "True") } /// Determines if the given dimensions represent a portrait orientation. diff --git a/src/processing/watermark.rs b/src/processing/watermark.rs index 1e25d59..7176486 100644 --- a/src/processing/watermark.rs +++ b/src/processing/watermark.rs @@ -1,5 +1,5 @@ -use crate::processing::options::Watermark; -use crate::processing::transform::{resize_with_algorithm, TransformError}; +use crate::processing::options::{Gravity, Watermark, WatermarkPosition}; +use crate::processing::transform::{calc_position, resize_with_algorithm, TransformError}; use bytes::Bytes; use libvips::{ops, VipsImage}; use thiserror::Error; @@ -96,6 +96,15 @@ pub fn prepare_cached_watermark(bytes: Bytes) -> Result, ) -> Result { let watermark_img = resolve_watermark_image(watermark)?; + if watermark_img.get_width() <= 0 || watermark_img.get_height() <= 0 { + return Ok(img); + } - // Resize watermark to be 1/4 of the main image's width, maintaining aspect ratio - let factor = (img.get_width() as f64 / 4.0) / watermark_img.get_width() as f64; + let fraction = if watermark_opts.scale > 0.0 { + watermark_opts.scale + } else { + DEFAULT_WATERMARK_WIDTH_FRACTION + }; + let factor = (f64::from(img.get_width()) * fraction) / f64::from(watermark_img.get_width()); let watermark_resized = resize_with_algorithm( &watermark_img, factor, @@ -119,32 +135,63 @@ pub fn apply_watermark( let watermark_with_alpha = ensure_alpha_channel(watermark_resized)?; // Apply opacity - let multipliers = &mut [1.0, 1.0, 1.0, watermark_opts.opacity as f64]; + let multipliers = &mut [1.0, 1.0, 1.0, f64::from(watermark_opts.opacity)]; let adders = &mut [0.0, 0.0, 0.0, 0.0]; let watermark_with_opacity = ops::linear(&watermark_with_alpha, multipliers, adders) .map_err(vips("Failed to apply opacity to watermark"))?; - // Calculate position - let (x, y) = calculate_watermark_position(&img, &watermark_with_opacity, &watermark_opts.position); + let watermark_on_canvas = place_watermark(&img, &watermark_with_opacity, watermark_opts)?; - // Composite watermark - let bg = &mut [0.0, 0.0, 0.0, 0.0]; // transparent - let options = ops::EmbedOptions { - extend: ops::Extend::Background, - background: bg.to_vec(), - }; + ops::composite_2(&img, &watermark_on_canvas, ops::BlendMode::Over).map_err(vips("Failed to composite watermark")) +} + +/// Builds a full-size canvas holding the watermark where the request wants it. +fn place_watermark(img: &VipsImage, watermark: &VipsImage, options: &Watermark) -> Result { + let (canvas_w, canvas_h) = (img.get_width(), img.get_height()); + + match options.position { + WatermarkPosition::Replicate => tile_watermark(watermark, canvas_w, canvas_h), + WatermarkPosition::Anchor(kind) => { + let gravity = Gravity { + kind, + x: options.x_offset, + y: options.y_offset, + }; + // Overflow is allowed so an offset can push part of the watermark + // off the edge, which is what a caller asking for a bleed wants. + let (x, y) = calc_position( + i64::from(canvas_w), + i64::from(canvas_h), + i64::from(watermark.get_width()), + i64::from(watermark.get_height()), + &gravity, + 1.0, + true, + ); + + let embed_options = ops::EmbedOptions { + extend: ops::Extend::Background, + background: vec![0.0, 0.0, 0.0, 0.0], + }; + ops::embed_with_opts(watermark, x as i32, y as i32, canvas_w, canvas_h, &embed_options) + .map_err(vips("Failed to embed watermark on canvas")) + } + } +} - let watermark_on_canvas = ops::embed_with_opts( - &watermark_with_opacity, - x as i32, - y as i32, - img.get_width(), - img.get_height(), - &options, - ) - .map_err(vips("Failed to embed watermark on canvas"))?; +/// Tiles the watermark across the whole image, for `re` positioning. +fn tile_watermark(watermark: &VipsImage, canvas_w: i32, canvas_h: i32) -> Result { + let (wm_w, wm_h) = (watermark.get_width().max(1), watermark.get_height().max(1)); + let across = tiles_needed(canvas_w, wm_w); + let down = tiles_needed(canvas_h, wm_h); - ops::composite_2(&img, &watermark_on_canvas, ops::BlendMode::Over).map_err(vips("Failed to composite watermark")) + let tiled = ops::replicate(watermark, across, down).map_err(vips("Failed to tile watermark"))?; + ops::extract_area(&tiled, 0, 0, canvas_w, canvas_h).map_err(vips("Failed to trim tiled watermark")) +} + +/// How many tiles of `tile` it takes to cover `extent`, rounding up. +fn tiles_needed(extent: i32, tile: i32) -> i32 { + ((extent + tile - 1) / tile).max(1) } fn resolve_watermark_image(watermark: &CachedWatermark) -> Result { @@ -183,24 +230,3 @@ fn build_prepared_watermark_image(watermark_img: VipsImage) -> Result (u32, u32) { - let main_w = main_img.get_width() as u32; - let main_h = main_img.get_height() as u32; - let wm_w = watermark_img.get_width() as u32; - let wm_h = watermark_img.get_height() as u32; - let margin = (main_w.min(main_h) as f32 * 0.05).round() as u32; // 5% margin - - match position { - "no" => ((main_w - wm_w) / 2, margin), - "so" => ((main_w - wm_w) / 2, main_h - wm_h - margin), - "ea" => (main_w - wm_w - margin, (main_h - wm_h) / 2), - "we" => (margin, (main_h - wm_h) / 2), - "nowe" => (margin, margin), - "noea" => (main_w - wm_w - margin, margin), - "sowe" => (margin, main_h - wm_h - margin), - "soea" => (main_w - wm_w - margin, main_h - wm_h - margin), - "ce" => ((main_w - wm_w) / 2, (main_h - wm_h) / 2), - _ => ((main_w - wm_w) / 2, (main_h - wm_h) / 2), - } -} diff --git a/src/service.rs b/src/service.rs deleted file mode 100644 index 794f104..0000000 --- a/src/service.rs +++ /dev/null @@ -1,1271 +0,0 @@ -use crate::app::AppState; -use crate::caching::cache::{CachedImage, CachedMetadata, ImgforgeCache, MetadataCache}; -use crate::config::DefaultOutputFormat; -use crate::fetch::{fetch_image, FetchError}; -use crate::limits::{MaxResultDimension, MaxSourceFileSize, MaxSourceResolution}; -use crate::monitoring::{ImageOperation, ImageOperationActivityGuard, ImageOperationPhase, ImageOperationTimer}; -use crate::processing::options::{parse_all_options, OptionParseError, ParsedOptions}; -use crate::processing::presets::{expand_presets, PresetError}; -use crate::processing::save::SaveError; -use crate::processing::transform::TransformError; -use crate::processing::watermark::{self, CachedWatermark}; -use crate::processing::{process_image, ProcessingError}; -use crate::url::{parse_path, validate_signature, ImgforgeUrl, SourceUrlDecodeError}; -use crate::utils::{content_type_to_format, format_to_content_type, read_exif_orientation}; -use axum::http::StatusCode; -use bytes::Bytes; -use libvips::VipsImage; -use std::borrow::Cow; -use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; -use thiserror::Error; -use tokio::fs; -use tracing::{debug, error, info}; - -/// Indicates whether the response was served from cache. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CacheStatus { - Hit, - Miss, -} - -impl CacheStatus { - pub fn as_header_value(&self) -> &'static str { - match self { - CacheStatus::Hit => "HIT", - CacheStatus::Miss => "MISS", - } - } -} - -/// Result of processing an image request. -pub struct ProcessedImage { - pub bytes: Bytes, - pub content_type: &'static str, - pub cache_status: CacheStatus, - pub content_disposition: Option, -} - -/// Result of fetching image metadata. -pub struct ImageInfo { - pub width: u32, - pub height: u32, - pub format: String, - pub content_type: Option, - pub size_bytes: usize, - pub channels: u32, - pub has_alpha: bool, - pub orientation: Option, -} - -/// Request context for processing or info retrieval. -pub struct ProcessRequest<'a> { - pub path: &'a str, - pub bearer_token: Option<&'a str>, -} - -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum ServiceError { - #[error(transparent)] - Fetch(#[from] FetchError), - #[error("failed to fetch watermark image")] - WatermarkFetch { - #[source] - source: FetchError, - }, - #[error(transparent)] - Preset(#[from] PresetError), - #[error(transparent)] - OptionParse(#[from] OptionParseError), - #[error(transparent)] - SourceUrlDecode(#[from] SourceUrlDecodeError), - #[error(transparent)] - Processing(#[from] ProcessingError), - #[error("failed to decode source image")] - SourceImageDecode { - #[source] - source: libvips::error::Error, - }, - #[error("{operation} blocking task failed")] - BlockingTask { - operation: &'static str, - #[source] - source: tokio::task::JoinError, - }, - #[error("{message}")] - Response { status: StatusCode, message: String }, -} - -impl ServiceError { - pub fn new(status: StatusCode, message: impl Into) -> Self { - Self::Response { - status, - message: message.into(), - } - } - - pub fn status(&self) -> StatusCode { - match self { - Self::Fetch(_) - | Self::WatermarkFetch { .. } - | Self::Preset(_) - | Self::OptionParse(_) - | Self::SourceUrlDecode(_) - | Self::SourceImageDecode { .. } => StatusCode::BAD_REQUEST, - Self::Processing(ProcessingError::Save(SaveError::Vips { .. } | SaveError::EncoderPanicked { .. })) => { - StatusCode::INTERNAL_SERVER_ERROR - } - Self::BlockingTask { .. } => StatusCode::INTERNAL_SERVER_ERROR, - Self::Processing(_) => StatusCode::BAD_REQUEST, - Self::Response { status, .. } => *status, - } - } - - pub fn message(&self) -> Cow<'_, str> { - match self { - Self::Fetch(FetchError::Request(_)) => Cow::Borrowed("Error fetching image"), - Self::Fetch(FetchError::ResponseBody(_)) => Cow::Borrowed("Error reading image bytes"), - Self::Fetch(FetchError::SourceTooLarge { limit, .. }) => Cow::Owned(format!( - "Source image exceeds the maximum allowed size of {limit} bytes" - )), - Self::WatermarkFetch { .. } => Cow::Borrowed("Failed to fetch watermark image"), - Self::Preset(error) => Cow::Owned(error.to_string()), - Self::OptionParse(error) => Cow::Owned(error.to_string()), - Self::SourceUrlDecode(_) => Cow::Borrowed("Error decoding URL"), - Self::Processing(ProcessingError::Save(SaveError::UnsupportedFormat { format })) => { - Cow::Owned(format!("Unsupported output format: {format}")) - } - Self::Processing(ProcessingError::Save(_)) => Cow::Borrowed("Failed to encode image"), - // Every InvalidArgument message describes the caller's own input — - // an out-of-range zoom, a padded canvas past what libvips will - // embed — so it is more useful in the response than "error - // processing image", and carries nothing internal. Vips failures - // stay generic. - Self::Processing(ProcessingError::Transform(TransformError::InvalidArgument { message, .. })) => { - Cow::Borrowed(message.as_str()) - } - Self::Processing(ProcessingError::ResultTooLarge { width, height, limit }) => Cow::Owned(format!( - "Processed image would be {width}x{height}, over the {limit}px result dimension limit" - )), - Self::Processing(_) => Cow::Borrowed("Error processing image"), - Self::SourceImageDecode { .. } => Cow::Borrowed("Failed to decode source image"), - Self::BlockingTask { .. } => Cow::Borrowed("Image operation failed"), - Self::Response { message, .. } => Cow::Borrowed(message), - } - } -} - -/// Default output format when the URL requests none (#45): the source -/// image's format (imgproxy-compatible — a transparent PNG stays a PNG -/// instead of being flattened to JPEG), or a fixed format when -/// IMGFORGE_DEFAULT_FORMAT names one. Returns None (-> JPEG fallback) -/// when the source can't be sniffed or this build can't encode it. -fn default_output_format(configured: DefaultOutputFormat, image_bytes: &[u8]) -> Option<&'static str> { - if let Some(format) = configured.fixed_format() { - return Some(format); - } - - sniff_image_format(image_bytes).filter(|format| crate::processing::save::is_format_supported(format)) -} - -fn processed_cache_key<'a>( - path: &'a str, - configured: DefaultOutputFormat, - has_explicit_format: bool, - is_raw: bool, - max_result_dimension: Option, -) -> Cow<'a, str> { - let base = if has_explicit_format || is_raw { - Cow::Borrowed(path) - } else { - Cow::Owned(format!("default-format={}:{}", configured.as_str(), path)) - }; - - // A raw response is the untouched source: nothing is processed, so the - // result ceiling cannot apply to it. It must also keep the bare path as its - // key, because that is what serve_raw_response inserts under — namespacing - // it here would make every raw request miss and refetch. - if is_raw { - return base; - } - - // A persistent cache outlives the configuration that filled it, so an entry - // stored before the ceiling existed — or under a higher one — would other- - // wise still be served, handing back the oversized image the limit exists - // to refuse. Namespacing by the effective limit retires those entries. - // Keys are left untouched when no limit applies, so enabling this feature - // does not invalidate an existing cache. - match max_result_dimension { - Some(limit) => Cow::Owned(format!("mrd={}:{}", limit.get(), base)), - None => base, - } -} - -/// Rewrites a crop region to match a source that was decoded at a reduced size. -/// -/// Rounds the region up: it is clamped to the image by `crop_image` anyway, and -/// rounding down could leave it fractionally smaller than the resize target, -/// which `enlarge:false` would then refuse to make up. -fn rescale_crop(parsed_options: &mut ParsedOptions, original: (i32, i32), shrunk: (i32, i32)) { - let Some(crop) = parsed_options.crop.as_mut() else { - return; - }; - let (ow, oh) = (f64::from(original.0), f64::from(original.1)); - let (sw, sh) = (f64::from(shrunk.0), f64::from(shrunk.1)); - if ow <= 0.0 || oh <= 0.0 || sw <= 0.0 || sh <= 0.0 { - return; - } - - // A zero extent already means "all of it" and stays that way. - if crop.width > 0 { - crop.width = ((f64::from(crop.width) * (sw / ow)).ceil() as u32).max(1); - } - if crop.height > 0 { - crop.height = ((f64::from(crop.height) * (sh / oh)).ceil() as u32).max(1); - } -} - -/// Whether EXIF orientation will transpose the image during processing. -fn swaps_axes(parsed_options: &ParsedOptions, image_bytes: &Bytes) -> bool { - parsed_options.auto_rotate - && matches!( - crate::utils::read_exif_orientation(image_bytes), - Some(5) | Some(6) | Some(7) | Some(8) - ) -} - -/// Reopens the source at a reduced scale when the plan allows it, falling back -/// to the image already opened. -/// -/// Only JPEG: its loader takes a power-of-two `shrink` and genuinely skips the -/// work. Other loaders either have no equivalent or spell it differently, and -/// naming a property a loader does not have makes libvips reject the whole -/// call — the failure mode that broke AVIF and GIF encoding. -fn shrink_source_on_load( - source_image: VipsImage, - image_bytes: &Bytes, - parsed_options: &mut ParsedOptions, -) -> VipsImage { - let format = sniff_image_format(image_bytes); - if !matches!(format, Some("jpeg") | Some("webp")) { - return source_image; - } - - let (width, height) = (source_image.get_width(), source_image.get_height()); - let (Ok(width), Ok(height)) = (u32::try_from(width), u32::try_from(height)) else { - return source_image; - }; - - // EXIF orientations 5-8 swap the axes, and the rotation happens after the - // load. The plan is written against what the viewer sees, so the factor has - // to be chosen against the rotated dimensions, not the stored ones. - let (width, height) = if swaps_axes(parsed_options, image_bytes) { - (height, width) - } else { - (width, height) - }; - - // JPEG takes a power-of-two shrink; WebP takes a continuous scale and can - // therefore decode much closer to what is actually needed. - let load_option = if format == Some("webp") { - crate::processing::load_scale_factor(parsed_options, width, height).map(|scale| format!("scale={scale}")) - } else { - match crate::processing::load_shrink_factor(parsed_options, width, height) { - factor if factor > 1 => Some(format!("shrink={factor}")), - _ => None, - } - }; - let Some(load_option) = load_option else { - return source_image; - }; - - match VipsImage::new_from_buffer(image_bytes, &load_option) { - Ok(shrunk) => { - debug!( - "Decoding with {}: {}x{} -> {}x{}", - load_option, - width, - height, - shrunk.get_width(), - shrunk.get_height() - ); - // A crop names a region of the source in pixels, and the source - // just got smaller. Rewrite it against what was actually decoded - // rather than the requested factor, so rounding in the loader - // cannot leave the region pointing at the wrong pixels. - rescale_crop( - parsed_options, - (source_image.get_width(), source_image.get_height()), - (shrunk.get_width(), shrunk.get_height()), - ); - shrunk - } - // Nothing is lost by carrying on with the image already opened. - Err(err) => { - debug!("Shrink-on-load unavailable, using the full-size source: {}", err); - source_image - } - } -} - -fn detect_image_format(content_type: Option<&str>, image_bytes: &[u8]) -> String { - if let Some(format) = content_type.and_then(content_type_to_format) { - return format.to_string(); - } - - sniff_image_format(image_bytes).unwrap_or("unknown").to_string() -} - -fn sniff_image_format(image_bytes: &[u8]) -> Option<&'static str> { - if image_bytes.len() >= 3 && image_bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { - return Some("jpeg"); - } - - if image_bytes.len() >= 8 && image_bytes.starts_with(b"\x89PNG\r\n\x1a\n") { - return Some("png"); - } - - if image_bytes.len() >= 6 && (image_bytes.starts_with(b"GIF87a") || image_bytes.starts_with(b"GIF89a")) { - return Some("gif"); - } - - if image_bytes.len() >= 12 && image_bytes.starts_with(b"RIFF") && &image_bytes[8..12] == b"WEBP" { - return Some("webp"); - } - - if image_bytes.len() >= 4 && (image_bytes.starts_with(b"II*\0") || image_bytes.starts_with(b"MM\0*")) { - return Some("tiff"); - } - - if image_bytes.len() >= 12 - && image_bytes[4..8] == *b"ftyp" - && matches!( - &image_bytes[8..12], - b"avif" | b"avis" | b"heic" | b"heix" | b"hevc" | b"hevx" | b"mif1" | b"msf1" - ) - { - let brand = &image_bytes[8..12]; - return Some(if brand == b"avif" || brand == b"avis" { - "avif" - } else { - "heif" - }); - } - - None -} - -fn image_has_alpha(channels: u32) -> bool { - matches!(channels, 2 | 4) -} - -/// Process an imgproxy-compatible path using the provided application state. -pub async fn process_path(state: Arc, request: ProcessRequest<'_>) -> Result { - let config = &state.config; - let path = request.path; - - info!("Imgforge request received path={}", path); - - let url_parts = parse_and_authorize(config, path, request.bearer_token)?; - - let expanded_options = expand_presets( - url_parts.processing_options.clone(), - &config.presets, - config.only_presets, - )?; - - let mut parsed_options = parse_all_options(expanded_options)?; - - enforce_expiration(&parsed_options)?; - - // Resolved before the cache lookup: the key depends on it. - parsed_options.max_result_dimension = resolve_max_result_dimension(config, &parsed_options); - - let cache_key = processed_cache_key( - path, - config.default_format, - parsed_options.format.is_some(), - parsed_options.raw, - parsed_options.max_result_dimension, - ); - - if let Some(cached_image) = state.cache.get(cache_key.as_ref()).await { - debug!("Image found in cache for path={}", path); - - return Ok(ProcessedImage { - bytes: cached_image.bytes, - content_type: cached_image.content_type, - cache_status: CacheStatus::Hit, - content_disposition: None, - }); - } - - let content_disposition = content_disposition_for(&parsed_options); - - let decoded_url = url_parts.source_url.decode()?; - - debug!("Processing image forge request for URL: {}", decoded_url); - - let max_src_file_size = resolve_max_src_file_size(config, &parsed_options).map(MaxSourceFileSize::get); - let (image_bytes, source_content_type) = fetch_image(&state.http_client, &decoded_url, max_src_file_size).await?; - - debug!( - "Source image MIME type: {:?}, size: {} bytes", - source_content_type, - image_bytes.len() - ); - - if parsed_options.raw { - return serve_raw_response( - state.as_ref(), - path, - image_bytes, - source_content_type, - content_disposition, - ) - .await; - } - - let watermark = if needs_watermark(&parsed_options) { - resolve_watermark(state.as_ref(), &parsed_options).await? - } else { - None - }; - - let waiting = ImageOperationActivityGuard::waiting(ImageOperation::Process); - let semaphore_wait = ImageOperationTimer::start(ImageOperation::Process, ImageOperationPhase::SemaphoreWait); - let permit = state - .semaphore - .clone() - .acquire_owned() - .await - .map_err(|_| ServiceError::new(StatusCode::INTERNAL_SERVER_ERROR, "Semaphore closed"))?; - drop(semaphore_wait); - - let blocking_state = state.clone(); - let span = tracing::Span::current(); - let blocking_queue = ImageOperationTimer::start(ImageOperation::Process, ImageOperationPhase::BlockingQueue); - let (processed_image_bytes, output_format) = tokio::task::spawn_blocking(move || { - drop(blocking_queue); - drop(waiting); - let _active = ImageOperationActivityGuard::active(ImageOperation::Process); - let _execution = ImageOperationTimer::start(ImageOperation::Process, ImageOperationPhase::Execution); - let _span_guard = span.enter(); - // Keep the concurrency slot until the blocking operation actually ends, - // even if the async request future is cancelled while awaiting it. - let _permit = permit; - - if parsed_options.format.is_none() { - parsed_options.format = - default_output_format(blocking_state.config.default_format, &image_bytes).map(str::to_owned); - } - let output_format = parsed_options.format.clone().unwrap_or_else(|| "jpeg".to_string()); - - let source_image = VipsImage::new_from_buffer(&image_bytes, "") - .map_err(|source| ServiceError::SourceImageDecode { source })?; - - enforce_security_constraints( - blocking_state.as_ref(), - &parsed_options, - &image_bytes, - source_content_type.as_deref(), - Some(&source_image), - )?; - - // Scale-on-load. The guards above must see the *original* dimensions, - // so this comes after them: shrinking first would let a source sneak - // past a resolution limit by arriving smaller than it really is. - // - // Opening an image does not decode it — libvips is demand-driven, so - // everything so far has read the header and nothing more. Reopening - // with a shrink means the full-resolution pixels are never unpacked at - // all. Same ordering imgproxy uses: load, check, then scale on load. - let source_image = shrink_source_on_load(source_image, &image_bytes, &mut parsed_options); - - let processed_image_bytes = process_image(source_image, parsed_options, &image_bytes, watermark.as_ref())?; - Ok::<_, ServiceError>((processed_image_bytes, output_format)) - }) - .await - .map_err(|source| ServiceError::BlockingTask { - operation: "image processing", - source, - })??; - - let content_type = format_to_content_type(&output_format); - if content_disposition.is_none() && !matches!(state.cache, ImgforgeCache::None) { - if let Err(err) = state.cache.insert( - cache_key.into_owned(), - CachedImage { - bytes: processed_image_bytes.clone(), - content_type, - }, - ) { - error!("Failed to cache image: {}", err); - } - } - - info!( - "Imgforge processed path={} output_format={} bytes={}", - path, - output_format, - processed_image_bytes.len() - ); - - Ok(ProcessedImage { - bytes: processed_image_bytes, - content_type, - cache_status: CacheStatus::Miss, - content_disposition, - }) -} - -/// Retrieve metadata for an image without processing it. -pub async fn image_info(state: Arc, request: ProcessRequest<'_>) -> Result { - let config = &state.config; - let path = request.path; - - debug!("Info path captured: {}", path); - let url_parts = parse_and_authorize(config, path, request.bearer_token)?; - - if let Some(cached_metadata) = state.metadata_cache.get(path).await { - debug!("Metadata found in cache for path={}", path); - return Ok(ImageInfo { - width: cached_metadata.width, - height: cached_metadata.height, - format: cached_metadata.format, - content_type: (!cached_metadata.content_type.is_empty()).then_some(cached_metadata.content_type), - size_bytes: cached_metadata.size_bytes, - channels: cached_metadata.channels, - has_alpha: cached_metadata.has_alpha, - orientation: (cached_metadata.orientation != 0).then_some(cached_metadata.orientation), - }); - } - - let decoded_url = url_parts.source_url.decode()?; - - let (image_bytes, content_type) = fetch_image(&state.http_client, &decoded_url, None).await?; - - let waiting = ImageOperationActivityGuard::waiting(ImageOperation::Info); - let semaphore_wait = ImageOperationTimer::start(ImageOperation::Info, ImageOperationPhase::SemaphoreWait); - let permit = state - .semaphore - .clone() - .acquire_owned() - .await - .map_err(|_| ServiceError::new(StatusCode::INTERNAL_SERVER_ERROR, "Semaphore closed"))?; - drop(semaphore_wait); - let info_content_type = content_type.clone(); - let span = tracing::Span::current(); - let blocking_queue = ImageOperationTimer::start(ImageOperation::Info, ImageOperationPhase::BlockingQueue); - let (width, height, image_format, channels, has_alpha, orientation, cacheable, size_bytes) = - tokio::task::spawn_blocking(move || { - drop(blocking_queue); - drop(waiting); - let _active = ImageOperationActivityGuard::active(ImageOperation::Info); - let _execution = ImageOperationTimer::start(ImageOperation::Info, ImageOperationPhase::Execution); - let _span_guard = span.enter(); - let _permit = permit; - let size_bytes = image_bytes.len(); - - match VipsImage::new_from_buffer(&image_bytes, "") { - Ok(img) => { - let format_str = detect_image_format(info_content_type.as_deref(), &image_bytes); - let channels = img.get_bands() as u32; - ( - img.get_width() as u32, - img.get_height() as u32, - format_str, - channels, - image_has_alpha(channels), - read_exif_orientation(&image_bytes), - true, - size_bytes, - ) - } - Err(err) => { - error!("Failed to decode image for info: {}", err); - (0, 0, "unknown".to_string(), 0, false, None, false, size_bytes) - } - } - }) - .await - .map_err(|source| ServiceError::BlockingTask { - operation: "image metadata", - source, - })?; - - let metadata = CachedMetadata { - width, - height, - format: image_format.clone(), - content_type: content_type.clone().unwrap_or_default(), - size_bytes, - channels, - has_alpha, - orientation: orientation.unwrap_or(0), - }; - - if cacheable && !matches!(state.metadata_cache, MetadataCache::None) { - if let Err(err) = state.metadata_cache.insert(path.to_string(), metadata) { - error!("Failed to cache metadata: {}", err); - } - } - - info!( - "Imgforge info served path={} width={} height={} format={} size_bytes={} channels={} has_alpha={} orientation={:?}", - path, width, height, image_format, size_bytes, channels, has_alpha, orientation - ); - - Ok(ImageInfo { - width, - height, - format: image_format, - content_type, - size_bytes, - channels, - has_alpha, - orientation, - }) -} - -fn parse_and_authorize( - config: &crate::config::Config, - path: &str, - bearer_token: Option<&str>, -) -> Result { - if let Some(secret) = config.secret.as_ref() { - if !secret.is_empty() { - match bearer_token { - Some(token) if token == secret => {} - Some(_) => { - error!("Invalid authorization token"); - return Err(ServiceError::new(StatusCode::FORBIDDEN, "Invalid authorization token")); - } - None => { - error!("Missing authorization token"); - return Err(ServiceError::new(StatusCode::FORBIDDEN, "Missing authorization token")); - } - } - } - } - - let url_parts = parse_path(path).ok_or_else(|| { - error!("Invalid URL format: {}", path); - ServiceError::new(StatusCode::BAD_REQUEST, "Invalid URL format") - })?; - - if url_parts.signature == "unsafe" { - if !config.allow_unsigned { - error!("Unsigned URLs are not allowed"); - return Err(ServiceError::new( - StatusCode::FORBIDDEN, - "Unsigned URLs are not allowed", - )); - } - } else { - let path_to_sign = build_path_to_sign(path).ok_or_else(|| { - error!("Invalid URL format: {}", path); - ServiceError::new(StatusCode::BAD_REQUEST, "Invalid URL format") - })?; - if !validate_signature(&config.key, &config.salt, &url_parts.signature, &path_to_sign) { - error!("Invalid signature for path: {}", path_to_sign); - return Err(ServiceError::new(StatusCode::FORBIDDEN, "Invalid signature")); - } - } - - Ok(url_parts) -} - -fn build_path_to_sign(path: &str) -> Option { - path.find('/').map(|idx| format!("/{}", &path[idx + 1..])) -} - -fn enforce_security_constraints( - state: &AppState, - parsed_options: &ParsedOptions, - image_bytes: &Bytes, - source_content_type: Option<&str>, - decoded_image: Option<&VipsImage>, -) -> Result<(), ServiceError> { - let config = &state.config; - - let max_src_file_size = resolve_max_src_file_size(config, parsed_options); - - if let Some(max_size) = max_src_file_size { - if image_bytes.len() > max_size.get() { - error!("Source image file size is too large"); - return Err(ServiceError::new( - StatusCode::BAD_REQUEST, - "Source image file size is too large", - )); - } - } - - if let Some(allowed_types) = &config.allowed_mime_types { - if let Some(content_type) = source_content_type { - if !allowed_types.contains(&content_type.to_string()) { - error!("Source image MIME type is not allowed: {}", content_type); - return Err(ServiceError::new( - StatusCode::BAD_REQUEST, - "Source image MIME type is not allowed", - )); - } - } - } - - let max_src_resolution = resolve_max_src_resolution(config, parsed_options); - - if let Some(max_res) = max_src_resolution { - let (w, h) = match decoded_image { - Some(img) => (img.get_width(), img.get_height()), - None => { - error!("Failed to load image for resolution check"); - return Err(ServiceError::new( - StatusCode::BAD_REQUEST, - "Failed to load image for resolution check", - )); - } - }; - debug!("Image resolution: {}x{}", w, h); - let source_pixels = checked_source_pixel_count(w, h)?; - if source_pixels > max_res.pixels() { - error!("Source image resolution is too large"); - return Err(ServiceError::new( - StatusCode::BAD_REQUEST, - "Source image resolution is too large", - )); - } - } - - Ok(()) -} - -fn checked_source_pixel_count(width: i32, height: i32) -> Result { - let width = u64::try_from(width) - .map_err(|_| ServiceError::new(StatusCode::BAD_REQUEST, "Invalid source image dimensions"))?; - let height = u64::try_from(height) - .map_err(|_| ServiceError::new(StatusCode::BAD_REQUEST, "Invalid source image dimensions"))?; - - width - .checked_mul(height) - .ok_or_else(|| ServiceError::new(StatusCode::BAD_REQUEST, "Source image resolution is too large")) -} - -fn resolve_max_src_file_size( - config: &crate::config::Config, - parsed_options: &ParsedOptions, -) -> Option { - if config.allow_security_options { - parsed_options.max_src_file_size.or(config.max_src_file_size) - } else { - config.max_src_file_size - } -} - -fn resolve_max_src_resolution( - config: &crate::config::Config, - parsed_options: &ParsedOptions, -) -> Option { - if config.allow_security_options { - parsed_options.max_src_resolution.or(config.max_src_resolution) - } else { - config.max_src_resolution - } -} - -fn resolve_max_result_dimension( - config: &crate::config::Config, - parsed_options: &ParsedOptions, -) -> Option { - if config.allow_security_options { - parsed_options.max_result_dimension.or(config.max_result_dimension) - } else { - config.max_result_dimension - } -} - -fn needs_watermark(parsed_options: &ParsedOptions) -> bool { - parsed_options.watermark.is_some() || parsed_options.watermark_url.is_some() -} - -async fn resolve_watermark( - state: &AppState, - parsed_options: &ParsedOptions, -) -> Result, ServiceError> { - if let Some(url) = &parsed_options.watermark_url { - debug!("Fetching watermark from URL: {}", url); - match fetch_image(&state.http_client, url, None).await { - Ok((bytes, _)) => Ok(Some(CachedWatermark::from_bytes(bytes))), - Err(source) => Err(ServiceError::WatermarkFetch { source }), - } - } else if parsed_options.watermark.is_some() { - if let Some(path) = &state.config.watermark_path { - let watermark = - state - .watermark_cache - .get_or_try_init(|| async { - debug!("Loading watermark from path: {} (cached on first load)", path); - let bytes = fs::read(path).await.map(Bytes::from).map_err(|e| { - error!("Failed to read watermark image from path: {}", e); - ServiceError::new(StatusCode::BAD_REQUEST, "Failed to read watermark image from path") - })?; - - let waiting = ImageOperationActivityGuard::waiting(ImageOperation::Watermark); - let semaphore_wait = - ImageOperationTimer::start(ImageOperation::Watermark, ImageOperationPhase::SemaphoreWait); - let permit = - state.semaphore.clone().acquire_owned().await.map_err(|_| { - ServiceError::new(StatusCode::INTERNAL_SERVER_ERROR, "Semaphore closed") - })?; - drop(semaphore_wait); - let span = tracing::Span::current(); - let blocking_queue = - ImageOperationTimer::start(ImageOperation::Watermark, ImageOperationPhase::BlockingQueue); - tokio::task::spawn_blocking(move || { - drop(blocking_queue); - drop(waiting); - let _active = ImageOperationActivityGuard::active(ImageOperation::Watermark); - let _execution = - ImageOperationTimer::start(ImageOperation::Watermark, ImageOperationPhase::Execution); - let _span_guard = span.enter(); - let _permit = permit; - watermark::prepare_cached_watermark(bytes) - }) - .await - .map_err(|source| ServiceError::BlockingTask { - operation: "watermark preparation", - source, - })? - .map_err(ProcessingError::from) - .map_err(ServiceError::from) - }) - .await?; - Ok(Some(watermark.clone())) - } else { - Ok(None) - } - } else { - Ok(None) - } -} - -async fn serve_raw_response( - state: &AppState, - path: &str, - image_bytes: Bytes, - source_content_type: Option, - content_disposition: Option, -) -> Result { - let content_type = source_content_type - .as_deref() - .map(format_to_content_type) - .unwrap_or("image/jpeg"); - - if content_disposition.is_none() && !matches!(state.cache, ImgforgeCache::None) { - if let Err(err) = state.cache.insert( - path.to_string(), - CachedImage { - bytes: image_bytes.clone(), - content_type, - }, - ) { - error!("Failed to cache raw image: {}", err); - } - } - - info!("Imgforge served raw path={} bytes={}", path, image_bytes.len()); - - Ok(ProcessedImage { - bytes: image_bytes, - content_type, - cache_status: CacheStatus::Miss, - content_disposition, - }) -} - -fn enforce_expiration(parsed_options: &ParsedOptions) -> Result<(), ServiceError> { - let Some(expires) = parsed_options.expires else { - return Ok(()); - }; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| ServiceError::new(StatusCode::INTERNAL_SERVER_ERROR, "system clock is before unix epoch"))? - .as_secs(); - - if now > expires { - return Err(ServiceError::new(StatusCode::NOT_FOUND, "URL has expired")); - } - - Ok(()) -} - -fn content_disposition_for(parsed_options: &ParsedOptions) -> Option { - let filename = parsed_options.filename.as_ref()?; - let disposition = if parsed_options.return_attachment { - "attachment" - } else { - "inline" - }; - Some(format!( - "{}; filename=\"{}\"", - disposition, - filename.replace(['\\', '"', '\r', '\n'], "_") - )) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::error::Error as _; - - #[test] - fn cache_keys_are_namespaced_by_the_effective_result_limit() { - let path = "/unsafe/resize:fit:4000:4000/example"; - let unlimited = processed_cache_key(path, DefaultOutputFormat::Source, false, false, None); - let limited = processed_cache_key( - path, - DefaultOutputFormat::Source, - false, - false, - Some("1000".parse().unwrap()), - ); - let raised = processed_cache_key( - path, - DefaultOutputFormat::Source, - false, - false, - Some("8192".parse().unwrap()), - ); - - // A disk cache outlives the config that filled it. Entries stored under - // one ceiling must not be served under another, or a request that the - // limit should refuse comes straight back out of the cache. - assert_ne!(unlimited, limited); - assert_ne!(limited, raised); - - // Turning the feature on must not invalidate caches that never use it. - assert_eq!( - unlimited, - processed_cache_key(path, DefaultOutputFormat::Source, false, false, None) - ); - } - - /// A real JPEG with an APP1/Exif segment carrying just the Orientation tag, - /// spliced in after SOI. Built by hand so the fixture needs no tooling and - /// no checked-in binary. - fn exif_orientation_jpeg(orientation: u16) -> Vec { - use image::{ImageBuffer, ImageFormat, Rgb}; - - let mut base = Vec::new(); - ImageBuffer::, Vec>::from_pixel(8, 4, Rgb([10, 20, 30])) - .write_to(&mut std::io::Cursor::new(&mut base), ImageFormat::Jpeg) - .unwrap(); - - let mut tiff = Vec::new(); - tiff.extend_from_slice(b"II"); // little-endian - tiff.extend_from_slice(&42u16.to_le_bytes()); - tiff.extend_from_slice(&8u32.to_le_bytes()); // IFD0 starts here - tiff.extend_from_slice(&1u16.to_le_bytes()); // one entry - tiff.extend_from_slice(&0x0112u16.to_le_bytes()); // Orientation - tiff.extend_from_slice(&3u16.to_le_bytes()); // SHORT - tiff.extend_from_slice(&1u32.to_le_bytes()); // count - tiff.extend_from_slice(&orientation.to_le_bytes()); - tiff.extend_from_slice(&0u16.to_le_bytes()); // value field is 4 bytes wide - tiff.extend_from_slice(&0u32.to_le_bytes()); // no further IFD - - let mut app1 = Vec::from(&b"Exif\0\0"[..]); - app1.extend_from_slice(&tiff); - - let mut out = Vec::new(); - out.extend_from_slice(&base[..2]); // SOI - out.extend_from_slice(&[0xFF, 0xE1]); - out.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); - out.extend_from_slice(&app1); - out.extend_from_slice(&base[2..]); - out - } - - #[test] - fn crop_regions_are_rewritten_for_a_reduced_decode() { - use crate::processing::options::Crop; - - // A crop names source pixels, so a source decoded at a quarter size - // needs the region quartered with it. Exercised through the function - // the request path actually calls, not a hand-rolled equivalent. - let original = (2000, 1600); - let shrunk = (500, 400); - - let mut options = ParsedOptions { - crop: Some(Crop { - x: 0, - y: 0, - width: 1000, - height: 800, - gravity: None, - }), - ..ParsedOptions::default() - }; - rescale_crop(&mut options, original, shrunk); - let crop = options.crop.unwrap(); - assert_eq!((crop.width, crop.height), (250, 200)); - - // A zero extent already means "all of it" and must stay that way, - // otherwise it would be pinned to one pixel. - let mut options = ParsedOptions { - crop: Some(Crop { - x: 0, - y: 0, - width: 0, - height: 800, - gravity: None, - }), - ..ParsedOptions::default() - }; - rescale_crop(&mut options, original, shrunk); - let crop = options.crop.unwrap(); - assert_eq!((crop.width, crop.height), (0, 200)); - - // Rounding up: a region that does not divide evenly must not come back - // smaller than the resize target needs. - let mut options = ParsedOptions { - crop: Some(Crop { - x: 0, - y: 0, - width: 999, - height: 3, - gravity: None, - }), - ..ParsedOptions::default() - }; - rescale_crop(&mut options, original, shrunk); - let crop = options.crop.unwrap(); - assert_eq!((crop.width, crop.height), (250, 1)); - } - - #[test] - fn axis_swapping_orientations_are_recognised() { - // The wiring that feeds load_shrink_factor its dimensions. Without this, - // a transposed source is measured on its stored shape and over-shrunk: - // the factor test proves the arithmetic, this proves it is reached. - let rotating = ParsedOptions { - auto_rotate: true, - ..ParsedOptions::default() - }; - let fixed = ParsedOptions { - auto_rotate: false, - ..ParsedOptions::default() - }; - - // A JPEG carrying orientation 6, which transposes the image. - let rotated_jpeg = Bytes::from(exif_orientation_jpeg(6)); - assert!( - swaps_axes(&rotating, &rotated_jpeg), - "orientation 6 transposes and must swap the axes" - ); - assert!( - !swaps_axes(&fixed, &rotated_jpeg), - "auto_rotate:false leaves the stored shape alone" - ); - - // Orientation 3 is a 180 rotation: same shape, no swap. - let upright_jpeg = Bytes::from(exif_orientation_jpeg(3)); - assert!(!swaps_axes(&rotating, &upright_jpeg)); - - // No EXIF at all. - assert!(!swaps_axes(&rotating, &Bytes::from_static(b"not an image"))); - } - - #[test] - fn raw_cache_keys_ignore_the_result_limit() { - // serve_raw_response inserts under the bare path, so the lookup key has - // to match it exactly or raw requests miss the cache forever and refetch - // the source every time. - let path = "/unsafe/raw/example"; - let limit = Some("1000".parse::().unwrap()); - - assert_eq!( - processed_cache_key(path, DefaultOutputFormat::Source, false, true, limit), - path - ); - assert_eq!( - processed_cache_key(path, DefaultOutputFormat::Source, false, true, None), - path - ); - } - - #[test] - fn max_result_dimension_override_requires_security_options() { - let request_limit = "1000".parse::().unwrap(); - let server_limit = "4000".parse::().unwrap(); - - let parsed_options = ParsedOptions { - max_result_dimension: Some(request_limit), - ..ParsedOptions::default() - }; - - let mut config = crate::config::Config::new(vec![0u8; 32], vec![0u8; 32]); - config.max_result_dimension = Some(server_limit); - - // Locked down: the URL cannot set its own ceiling, so the server's stands. - config.allow_security_options = false; - assert_eq!( - resolve_max_result_dimension(&config, &parsed_options), - Some(server_limit) - ); - - // Opted in: the request wins, matching how max_src_* already behave. - config.allow_security_options = true; - assert_eq!( - resolve_max_result_dimension(&config, &parsed_options), - Some(request_limit) - ); - - // No server limit and no opt-in means no ceiling at all. - config.allow_security_options = false; - config.max_result_dimension = None; - assert_eq!(resolve_max_result_dimension(&config, &parsed_options), None); - } - - #[test] - fn fetch_size_error_has_centralized_http_mapping() { - let error = ServiceError::from(FetchError::SourceTooLarge { - limit: 1024, - actual: Some(2048), - }); - - assert_eq!(error.status(), StatusCode::BAD_REQUEST); - assert_eq!( - error.message(), - "Source image exceeds the maximum allowed size of 1024 bytes" - ); - assert!(matches!( - error, - ServiceError::Fetch(FetchError::SourceTooLarge { - limit: 1024, - actual: Some(2048) - }) - )); - } - - #[tokio::test] - async fn fetch_request_error_does_not_expose_network_details() { - let source = reqwest::Client::new() - .get("not_a_valid_url") - .send() - .await - .expect_err("invalid URL should fail"); - let error = ServiceError::from(FetchError::Request(source)); - - assert_eq!(error.status(), StatusCode::BAD_REQUEST); - assert_eq!(error.message(), "Error fetching image"); - assert!(error.source().is_some()); - } - - #[tokio::test] - async fn blocking_task_failure_maps_to_internal_server_error() { - let source = tokio::task::spawn_blocking(|| panic!("test blocking-task panic")) - .await - .expect_err("panicking task should return a join error"); - let error = ServiceError::BlockingTask { - operation: "test image operation", - source, - }; - - assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert_eq!(error.message(), "Image operation failed"); - assert!(error.source().is_some()); - } - - #[test] - fn option_parse_error_has_centralized_http_mapping() { - let error = ServiceError::from(OptionParseError::InvalidValue( - "quality option requires one argument".to_string(), - )); - - assert_eq!(error.status(), StatusCode::BAD_REQUEST); - assert_eq!(error.message(), "quality option requires one argument"); - } - - #[test] - fn source_url_error_uses_safe_client_message() { - use base64::Engine as _; - - let source = crate::url::SourceUrlInfo::Base64 { - encoded_url: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0xff]), - } - .decode() - .expect_err("invalid UTF-8 should fail"); - let error = ServiceError::from(source); - - assert_eq!(error.status(), StatusCode::BAD_REQUEST); - assert_eq!(error.message(), "Error decoding URL"); - assert!(error.source().is_some()); - } - - #[test] - fn processing_error_preserves_vips_source_and_uses_safe_client_message() { - let transform_error = crate::processing::transform::TransformError::Vips { - operation: "test resize", - source: libvips::error::Error::ResizeError, - }; - let error = ServiceError::from(ProcessingError::from(transform_error)); - - assert_eq!(error.status(), StatusCode::BAD_REQUEST); - assert_eq!(error.message(), "Error processing image"); - assert!(error.source().is_some()); - } - - #[test] - fn encoder_failure_maps_to_internal_server_error() { - let save_error = SaveError::Vips { - format: "JPEG", - source: libvips::error::Error::JpegsaveBufferError, - }; - let error = ServiceError::from(ProcessingError::from(save_error)); - - assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert_eq!(error.message(), "Failed to encode image"); - assert!(error.source().is_some()); - } - - #[test] - fn pixel_count_does_not_overflow_i32_sized_dimensions() { - assert_eq!(checked_source_pixel_count(50_000, 50_000).unwrap(), 2_500_000_000); - } - - #[test] - fn pixel_count_rejects_negative_dimensions() { - assert!(checked_source_pixel_count(-1, 100).is_err()); - assert!(checked_source_pixel_count(100, -1).is_err()); - } - - #[test] - fn fixed_default_format_is_resolved_without_sniffing() { - assert_eq!( - default_output_format(DefaultOutputFormat::Jpeg, b"not an image"), - Some("jpeg") - ); - assert_eq!( - default_output_format(DefaultOutputFormat::Heif, b"not an image"), - Some("heif") - ); - } - - #[test] - fn implicit_format_cache_keys_include_the_configured_default() { - let source_key = processed_cache_key("/unsafe/example", DefaultOutputFormat::Source, false, false, None); - let jpeg_key = processed_cache_key("/unsafe/example", DefaultOutputFormat::Jpeg, false, false, None); - let explicit_key = processed_cache_key( - "/unsafe/format:png/example", - DefaultOutputFormat::Jpeg, - true, - false, - None, - ); - - assert_ne!(source_key, jpeg_key); - assert_eq!(explicit_key, "/unsafe/format:png/example"); - } -} diff --git a/src/service/cache_key.rs b/src/service/cache_key.rs new file mode 100644 index 0000000..b7f1958 --- /dev/null +++ b/src/service/cache_key.rs @@ -0,0 +1,207 @@ +//! Deriving the cache key for a processed response. +//! +//! The path alone is not enough. Anything that changes the bytes without +//! changing the URL — the configured default format, a negotiated format from +//! the client's `Accept`, the effective security ceilings — has to be part of +//! the key, or a persistent cache will hand one client an entry that was +//! produced for another. +//! +//! The security ceilings are here for a second reason, and it is the one that +//! matters most. Every one of them is checked *after* the cache lookup, so an +//! entry stored while a limit was loose keeps being served once the limit is +//! tightened: the request is answered before the check it should have failed. +//! Namespacing by the effective limit is what retires those entries, and it has +//! to cover the limits that describe the *source* as well as the result, +//! because `raw` and `skip_processing` return source bytes straight from the +//! cache without ever reaching `enforce_source_constraints`. + +use crate::config::DefaultOutputFormat; +use crate::limits::{ + MaxAnimationFrameResolution, MaxAnimationFrames, MaxResultDimension, MaxSourceFileSize, MaxSourceResolution, +}; +use crate::processing::options::OptionDefaults; +use sha2::{Digest, Sha256}; +use std::borrow::Cow; + +/// Everything outside the URL path that changes the response bytes, or that +/// decides whether the response may be produced at all. +#[derive(Debug, Clone, Copy)] +pub struct CacheKeyParts<'a> { + pub path: &'a str, + pub default_format: DefaultOutputFormat, + pub has_explicit_format: bool, + pub is_raw: bool, + pub max_result_dimension: Option, + /// Frame ceiling in force, when one is. + pub max_animation_frames: Option, + /// Per-frame pixel ceiling in force, when one is. + pub max_animation_frame_resolution: Option, + /// Source-resolution ceiling in force, when one is. + pub max_src_resolution: Option, + /// Source-size ceiling in force, when one is. + pub max_src_file_size: Option, + /// The configured `allowed_mime_types`, when the deployment restricts them. + pub allowed_mime_types: Option<&'a [String]>, + /// The server-side watermark file, when the request composites one. + /// + /// `watermark:1` names no image of its own: the overlay comes from + /// `IMGFORGE_WATERMARK_PATH`. Repointing that setting changes the bytes of + /// every watermarked response while leaving every URL identical, so without + /// this the old logo is served until the entries age out. + pub watermark_path: Option<&'a str>, + /// Configured option defaults, when the deployment changes any of them. + /// + /// These seed the parse, so `IMGFORGE_QUALITY` and its neighbours change the + /// bytes exactly as a URL option would — and unlike a release, a config + /// change carries no version bump to retire the entries it invalidates. + pub option_defaults: Option, + /// Format chosen from the request's `Accept` header, when one was. + pub negotiated_format: Option<&'static str>, +} + +pub fn processed_cache_key<'a>(parts: CacheKeyParts<'a>) -> Cow<'a, str> { + // A raw response is the untouched source, so the format decision and the + // result ceiling cannot apply to it — but the limits describing the source + // very much can, and they are the only thing standing between a tightened + // policy and the bytes already in the cache. + if parts.is_raw { + return source_limits(parts, Cow::Borrowed(parts.path)); + } + + let base = if parts.has_explicit_format { + Cow::Borrowed(parts.path) + } else { + match parts.negotiated_format { + // Content negotiation makes one URL produce different bytes for + // different clients, so the chosen format is part of the identity + // of the entry. Without this a Chrome request would poison the + // cache for a client that cannot read AVIF. + Some(format) => Cow::Owned(format!("accept-format={format}:{}", parts.path)), + None => Cow::Owned(format!( + "default-format={}:{}", + parts.default_format.as_str(), + parts.path + )), + } + }; + + // The ceilings that describe the *result*. Changing any of them retires the + // entries stored under the previous setting, including the change from + // "unset" to a value: an unset limit contributes nothing to the key and a + // set one contributes its prefix. That is the intended cost — a cold cache + // for the affected URLs — and not a property to preserve. Only a deployment + // that never sets them keeps the keys it already had. + let base = match parts.max_result_dimension { + Some(limit) => Cow::Owned(format!("mrd={}:{}", limit.get(), base)), + None => base, + }; + + let base = match parts.max_animation_frames { + Some(limit) => Cow::Owned(format!("maf={}:{}", limit.get(), base)), + None => base, + }; + + let base = match parts.max_animation_frame_resolution { + Some(limit) => Cow::Owned(format!("mafr={}:{}", limit.pixels(), base)), + None => base, + }; + + source_limits(parts, base) +} + +/// Namespaces a key by the limits that describe the source rather than the +/// result. +/// +/// Shared by both paths because both can return source bytes: `skip_processing` +/// takes the processed key, `raw` takes the bare path, and neither reaches the +/// source checks on a cache hit. +fn source_limits<'a>(parts: CacheKeyParts<'a>, base: Cow<'a, str>) -> Cow<'a, str> { + let base = match parts.max_src_resolution { + Some(limit) => Cow::Owned(format!("msr={}:{}", limit.pixels(), base)), + None => base, + }; + + let base = match parts.max_src_file_size { + Some(limit) => Cow::Owned(format!("msfs={}:{}", limit.get(), base)), + None => base, + }; + + let base = match parts.allowed_mime_types { + Some(types) => Cow::Owned(format!("amt={}:{}", mime_digest(types), base)), + None => base, + }; + + let base = match parts.watermark_path { + Some(path) => Cow::Owned(format!("wm={}:{}", digest(&[path.as_bytes()]), base)), + None => base, + }; + + match parts.option_defaults { + Some(defaults) => Cow::Owned(format!("od={}:{}", defaults_digest(&defaults), base)), + None => base, + } +} + +/// A short, stable digest of the configured option defaults. +/// +/// Destructured rather than read field by field, so adding an option to +/// `OptionDefaults` fails to compile here until it is accounted for — the point +/// being not to grow a second copy of the struct that quietly falls behind it. +fn defaults_digest(defaults: &OptionDefaults) -> String { + let OptionDefaults { + auto_rotate, + strip_metadata, + keep_copyright, + strip_color_profile, + preserve_hdr, + enforce_thumbnail, + return_attachment, + quality, + } = *defaults; + + let flags = [ + u8::from(auto_rotate), + u8::from(strip_metadata), + u8::from(keep_copyright), + u8::from(strip_color_profile), + u8::from(preserve_hdr), + u8::from(enforce_thumbnail), + u8::from(return_attachment), + u8::from(quality.is_some()), + quality.unwrap_or(0), + ]; + digest(&[&flags]) +} + +/// A short, stable digest of the permitted MIME types. +/// +/// The list itself would make the key unbounded, and the key only has to change +/// when the list does. Sorted first so that reordering the environment variable +/// is not treated as a policy change, and hashed rather than truncated so two +/// different lists cannot collide into one namespace. `sha2` is already a +/// dependency for URL signing, and the digest is stable across releases in a way +/// `DefaultHasher` explicitly is not. +fn mime_digest(types: &[String]) -> String { + let mut sorted: Vec<&str> = types.iter().map(String::as_str).collect(); + sorted.sort_unstable(); + sorted.dedup(); + + let parts: Vec<&[u8]> = sorted.iter().map(|entry| entry.as_bytes()).collect(); + digest(&parts) +} + +/// Six bytes of SHA-256 over the parts, separated so that concatenations of +/// different shapes cannot collide. +fn digest(parts: &[&[u8]]) -> String { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update(part); + hasher.update([0u8]); + } + hasher + .finalize() + .iter() + .take(6) + .map(|byte| format!("{byte:02x}")) + .collect() +} diff --git a/src/service/error.rs b/src/service/error.rs new file mode 100644 index 0000000..edb3605 --- /dev/null +++ b/src/service/error.rs @@ -0,0 +1,116 @@ +//! The one place a failure is turned into a status code and a message the +//! client is allowed to see. +//! +//! Every variant keeps its cause as an error source for the logs; `message` +//! decides separately what goes on the wire, so an internal detail cannot leak +//! into a response by being convenient to format. + +use crate::fetch::FetchError; +use crate::processing::options::OptionParseError; +use crate::processing::presets::PresetError; +use crate::processing::save::SaveError; +use crate::processing::transform::TransformError; +use crate::processing::ProcessingError; +use crate::url::SourceUrlDecodeError; +use axum::http::StatusCode; +use std::borrow::Cow; +use thiserror::Error; + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ServiceError { + #[error(transparent)] + Fetch(#[from] FetchError), + #[error("failed to fetch watermark image")] + WatermarkFetch { + #[source] + source: FetchError, + }, + #[error(transparent)] + Preset(#[from] PresetError), + #[error(transparent)] + OptionParse(#[from] OptionParseError), + #[error(transparent)] + SourceUrlDecode(#[from] SourceUrlDecodeError), + #[error(transparent)] + Processing(#[from] ProcessingError), + #[error("failed to decode source image")] + SourceImageDecode { + #[source] + source: libvips::error::Error, + }, + #[error("{operation} blocking task failed")] + BlockingTask { + operation: &'static str, + #[source] + source: tokio::task::JoinError, + }, + #[error("{message}")] + Response { status: StatusCode, message: String }, +} + +impl ServiceError { + pub fn new(status: StatusCode, message: impl Into) -> Self { + Self::Response { + status, + message: message.into(), + } + } + + pub fn status(&self) -> StatusCode { + match self { + Self::Fetch(_) + | Self::WatermarkFetch { .. } + | Self::Preset(_) + | Self::OptionParse(_) + | Self::SourceUrlDecode(_) + | Self::SourceImageDecode { .. } => StatusCode::BAD_REQUEST, + Self::Processing(ProcessingError::Save(SaveError::Vips { .. } | SaveError::EncoderPanicked { .. })) => { + StatusCode::INTERNAL_SERVER_ERROR + } + Self::BlockingTask { .. } => StatusCode::INTERNAL_SERVER_ERROR, + Self::Processing(_) => StatusCode::BAD_REQUEST, + Self::Response { status, .. } => *status, + } + } + + pub fn message(&self) -> Cow<'_, str> { + match self { + Self::Fetch(FetchError::Request(_)) => Cow::Borrowed("Error fetching image"), + Self::Fetch(FetchError::ResponseBody(_)) => Cow::Borrowed("Error reading image bytes"), + Self::Fetch(FetchError::SourceTooLarge { limit, .. }) => Cow::Owned(format!( + "Source image exceeds the maximum allowed size of {limit} bytes" + )), + Self::Fetch(FetchError::SourceNotAllowed) => Cow::Borrowed("Source URL is not allowed"), + Self::Fetch(FetchError::UpstreamStatus { status }) => { + Cow::Owned(format!("Source responded with status {status}")) + } + Self::WatermarkFetch { .. } => Cow::Borrowed("Failed to fetch watermark image"), + Self::Preset(error) => Cow::Owned(error.to_string()), + Self::OptionParse(error) => Cow::Owned(error.to_string()), + Self::SourceUrlDecode(_) => Cow::Borrowed("Error decoding URL"), + Self::Processing(ProcessingError::Save(SaveError::UnsupportedFormat { format })) => { + Cow::Owned(format!("Unsupported output format: {format}")) + } + Self::Processing(ProcessingError::Save(_)) => Cow::Borrowed("Failed to encode image"), + // Every InvalidArgument message describes the caller's own input — + // an out-of-range zoom, a padded canvas past what libvips will + // embed — so it is more useful in the response than "error + // processing image", and carries nothing internal. Vips failures + // stay generic. + Self::Processing(ProcessingError::Transform(TransformError::InvalidArgument { message, .. })) => { + Cow::Borrowed(message.as_str()) + } + Self::Processing(ProcessingError::ResultTooLarge { width, height, limit }) => Cow::Owned(format!( + "Processed image would be {width}x{height}, over the {limit}px result dimension limit" + )), + Self::Processing(ProcessingError::FrameTooLarge { width, height, limit }) => Cow::Owned(format!( + "Animation frame is {width}x{height}, over the {limit} pixel frame limit" + )), + Self::Processing(_) => Cow::Borrowed("Error processing image"), + Self::SourceImageDecode { .. } => Cow::Borrowed("Failed to decode source image"), + Self::BlockingTask { .. } => Cow::Borrowed("Image operation failed"), + Self::Response { message, .. } => Cow::Borrowed(message), + } + } +} diff --git a/src/service/mod.rs b/src/service/mod.rs new file mode 100644 index 0000000..75af70d --- /dev/null +++ b/src/service/mod.rs @@ -0,0 +1,633 @@ +//! Request handling between the HTTP layer and the processing pipeline. + +pub mod cache_key; +pub mod error; +pub mod security; +pub mod source; + +pub use error::ServiceError; + +use crate::app::AppState; +use crate::caching::cache::{CachedImage, CachedMetadata, ImgforgeCache, MetadataCache}; +use crate::config::{Config, DefaultOutputFormat}; +use crate::fetch::{fetch_image, FetchedImage}; +use crate::limits::MaxSourceFileSize; +use crate::monitoring::{ImageOperation, ImageOperationActivityGuard, ImageOperationPhase, ImageOperationTimer}; +use crate::processing::metadata; +use crate::processing::options::{parse_all_options_with_defaults, OptionDefaults, ParsedOptions}; +use crate::processing::presets::expand_presets; +use crate::processing::watermark::{self, CachedWatermark}; +use crate::processing::{process_image, ProcessingError}; +use crate::url::{parse_path, validate_signature, ImgforgeUrl}; +use crate::utils::{format_to_content_type, read_exif_orientation}; +use axum::http::StatusCode; +use bytes::Bytes; +use cache_key::CacheKeyParts; +use libvips::VipsImage; +use security::{ + apply_effective_limits, enforce_expiration, enforce_security_constraints, enforce_source_constraints, + resolve_max_src_file_size, resolve_max_src_resolution, +}; +use source::{ + can_skip_processing, detect_image_format, image_has_alpha, loader_options, shrink_source_on_load, + sniff_image_format, +}; +use std::sync::Arc; +use tokio::fs; +use tracing::{debug, error, info}; + +/// Indicates whether the response was served from cache. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CacheStatus { + Hit, + Miss, +} + +impl CacheStatus { + pub fn as_header_value(&self) -> &'static str { + match self { + CacheStatus::Hit => "HIT", + CacheStatus::Miss => "MISS", + } + } +} + +/// Result of processing an image request. +pub struct ProcessedImage { + pub bytes: Bytes, + pub content_type: &'static str, + pub cache_status: CacheStatus, + pub content_disposition: Option, +} + +/// Result of fetching image metadata. +pub struct ImageInfo { + pub width: u32, + pub height: u32, + pub format: String, + pub content_type: Option, + pub size_bytes: usize, + pub channels: u32, + pub has_alpha: bool, + pub orientation: Option, + pub pages: u32, +} + +/// Request context for processing or info retrieval. +pub struct ProcessRequest<'a> { + pub path: &'a str, + pub bearer_token: Option<&'a str>, +} + +/// Default output format when the URL requests none (#45): the source +/// image's format (imgproxy-compatible — a transparent PNG stays a PNG +/// instead of being flattened to JPEG), or a fixed format when +/// IMGFORGE_DEFAULT_FORMAT names one. Returns None (-> JPEG fallback) +/// when the source can't be sniffed or this build can't encode it. +fn default_output_format(configured: DefaultOutputFormat, image_bytes: &[u8]) -> Option<&'static str> { + if let Some(format) = configured.fixed_format() { + return Some(format); + } + + sniff_image_format(image_bytes).filter(|format| crate::processing::save::is_format_supported(format)) +} + +/// Process an imgproxy-compatible path using the provided application state. +pub async fn process_path(state: Arc, request: ProcessRequest<'_>) -> Result { + let config = &state.config; + let path = request.path; + + info!("Imgforge request received path={}", path); + + let url_parts = parse_and_authorize(config, path, request.bearer_token)?; + + let expanded_options = expand_presets( + url_parts.processing_options.clone(), + &config.presets, + config.only_presets, + )?; + + let mut parsed_options = parse_all_options_with_defaults(expanded_options, config.option_defaults())?; + + enforce_expiration(&parsed_options)?; + + // Resolved before the cache lookup: the key depends on the ceilings. + apply_effective_limits(config, &mut parsed_options); + + let cache_key = cache_key::processed_cache_key(CacheKeyParts { + path, + default_format: config.default_format, + has_explicit_format: parsed_options.format.is_some(), + is_raw: parsed_options.raw, + max_result_dimension: parsed_options.max_result_dimension, + max_animation_frames: parsed_options.max_animation_frames, + max_animation_frame_resolution: parsed_options.max_animation_frame_resolution, + // The source ceilings decide whether these bytes may be served at all, + // and every one of them is checked after this lookup — so without them + // in the key a tightened policy is simply outrun by the cache. + max_src_resolution: resolve_max_src_resolution(config, &parsed_options), + max_src_file_size: resolve_max_src_file_size(config, &parsed_options), + allowed_mime_types: config.allowed_mime_types.as_deref(), + // Only when the request actually composites the server-side watermark: + // a `watermark_url` brings its own image, and a request using neither is + // unaffected by the setting. + watermark_path: config + .watermark_path + .as_deref() + .filter(|_| parsed_options.watermark.is_some() && parsed_options.watermark_url.is_none()), + // Only when the deployment changes them, so a default configuration + // keeps the keys it already had. + option_defaults: Some(config.option_defaults()).filter(|defaults| *defaults != OptionDefaults::default()), + negotiated_format: None, + }); + + if let Some(cached_image) = state.cache.get(cache_key.as_ref()).await { + debug!("Image found in cache for path={}", path); + + return Ok(ProcessedImage { + bytes: cached_image.bytes, + content_type: cached_image.content_type, + cache_status: CacheStatus::Hit, + content_disposition: None, + }); + } + + let content_disposition = content_disposition_for(&parsed_options); + + let decoded_url = url_parts.source_url.decode()?; + + debug!("Processing image forge request for URL: {}", decoded_url); + + let max_src_file_size = resolve_max_src_file_size(config, &parsed_options).map(MaxSourceFileSize::get); + let fetched = fetch_image(&state.http_client, &decoded_url, max_src_file_size).await?; + let FetchedImage { + bytes: image_bytes, + content_type: source_content_type, + .. + } = fetched; + + debug!( + "Source image MIME type: {:?}, size: {} bytes", + source_content_type, + image_bytes.len() + ); + + let source_format = sniff_image_format(&image_bytes); + + // `raw` returns the source untouched; `skip_processing` does the same for + // the formats it names, which is how imgproxy keeps an already-optimised + // asset from being re-encoded. + if parsed_options.raw || can_skip_processing(&parsed_options, source_format, config.default_format) { + // Skipping the pipeline is not a way around the limits that describe + // the source. `allowed_mime_types` and `max_src_resolution` say what + // this deployment is willing to serve, not merely what it is willing to + // re-encode, so a URL that opts out of processing still has to satisfy + // them before any bytes go back. + enforce_source_constraints( + state.as_ref(), + &parsed_options, + &image_bytes, + source_content_type.as_deref(), + )?; + + return serve_source_response( + state.as_ref(), + path, + &cache_key, + image_bytes, + source_content_type, + content_disposition, + ) + .await; + } + + let watermark = if needs_watermark(&parsed_options) { + resolve_watermark(state.as_ref(), &parsed_options).await? + } else { + None + }; + + let waiting = ImageOperationActivityGuard::waiting(ImageOperation::Process); + let semaphore_wait = ImageOperationTimer::start(ImageOperation::Process, ImageOperationPhase::SemaphoreWait); + let permit = state + .semaphore + .clone() + .acquire_owned() + .await + .map_err(|_| ServiceError::new(StatusCode::INTERNAL_SERVER_ERROR, "Semaphore closed"))?; + drop(semaphore_wait); + + let blocking_state = state.clone(); + let span = tracing::Span::current(); + let blocking_queue = ImageOperationTimer::start(ImageOperation::Process, ImageOperationPhase::BlockingQueue); + let (processed_image_bytes, output_format) = tokio::task::spawn_blocking(move || { + drop(blocking_queue); + drop(waiting); + let _active = ImageOperationActivityGuard::active(ImageOperation::Process); + let _execution = ImageOperationTimer::start(ImageOperation::Process, ImageOperationPhase::Execution); + let _span_guard = span.enter(); + // Keep the concurrency slot until the blocking operation actually ends, + // even if the async request future is cancelled while awaiting it. + let _permit = permit; + + if parsed_options.format.is_none() { + parsed_options.format = + default_output_format(blocking_state.config.default_format, &image_bytes).map(str::to_owned); + } + // Normalised once, here, because everything downstream keys off this + // string — the response `Content-Type`, the `format_quality` lookup and + // the metrics label. A URL naming an alias such as `format:tif` would + // otherwise pick the right encoder and then be described by the wrong + // MIME type, and be counted under a format name of its own. + let output_format = parsed_options + .format + .as_deref() + .and_then(crate::processing::save::canonical_format_name) + .map(str::to_owned) + .or_else(|| parsed_options.format.clone()) + .unwrap_or_else(|| "jpeg".to_string()); + parsed_options.format = Some(output_format.clone()); + + let (source_image, image_bytes) = open_source(&image_bytes, &parsed_options, &output_format)?; + + enforce_security_constraints( + blocking_state.as_ref(), + &parsed_options, + &image_bytes, + source_content_type.as_deref(), + Some(&source_image), + )?; + + // Scale-on-load. The guards above must see the *original* dimensions, + // so this comes after them: shrinking first would let a source sneak + // past a resolution limit by arriving smaller than it really is. + // + // Opening an image does not decode it — libvips is demand-driven, so + // everything so far has read the header and nothing more. Reopening + // with a shrink means the full-resolution pixels are never unpacked at + // all. Same ordering imgproxy uses: load, check, then scale on load. + let load_options = loader_options(&parsed_options, sniff_image_format(&image_bytes), &output_format); + let source_image = shrink_source_on_load(source_image, &image_bytes, &mut parsed_options, &load_options); + + let processed_image_bytes = process_image(source_image, parsed_options, &image_bytes, watermark.as_ref())?; + Ok::<_, ServiceError>((processed_image_bytes, output_format)) + }) + .await + .map_err(|source| ServiceError::BlockingTask { + operation: "image processing", + source, + })??; + + let content_type = format_to_content_type(&output_format); + if content_disposition.is_none() && !matches!(state.cache, ImgforgeCache::None) { + if let Err(err) = state.cache.insert( + cache_key.into_owned(), + CachedImage { + bytes: processed_image_bytes.clone(), + content_type, + }, + ) { + error!("Failed to cache image: {}", err); + } + } + + info!( + "Imgforge processed path={} output_format={} bytes={}", + path, + output_format, + processed_image_bytes.len() + ); + + Ok(ProcessedImage { + bytes: processed_image_bytes, + content_type, + cache_status: CacheStatus::Miss, + content_disposition, + }) +} + +/// Opens the source, honouring `enforce_thumbnail` and the multi-page plan. +/// +/// Returns the bytes that were actually opened alongside the image, because +/// choosing the embedded thumbnail replaces them — everything downstream that +/// reads EXIF or sniffs the format has to see the same bytes the pixels came +/// from. +fn open_source( + image_bytes: &Bytes, + parsed_options: &ParsedOptions, + output_format: &str, +) -> Result<(VipsImage, Bytes), ServiceError> { + if parsed_options.enforce_thumbnail { + if let Some(thumbnail) = metadata::embedded_thumbnail(image_bytes) { + let thumbnail = Bytes::from(thumbnail); + match VipsImage::new_from_buffer(&thumbnail, "") { + Ok(img) => { + debug!("Using the source's embedded thumbnail ({} bytes)", thumbnail.len()); + return Ok((img, thumbnail)); + } + // A thumbnail that will not decode is not a reason to fail the + // request; the full image is still there. + Err(err) => debug!("Embedded thumbnail did not decode ({}); using the full image", err), + } + } + } + + let load_options = loader_options(parsed_options, sniff_image_format(image_bytes), output_format); + let image = VipsImage::new_from_buffer(image_bytes, &load_options) + .map_err(|source| ServiceError::SourceImageDecode { source })?; + + Ok((image, image_bytes.clone())) +} + +/// Retrieve metadata for an image without processing it. +pub async fn image_info(state: Arc, request: ProcessRequest<'_>) -> Result { + let config = &state.config; + let path = request.path; + + debug!("Info path captured: {}", path); + let url_parts = parse_and_authorize(config, path, request.bearer_token)?; + + if let Some(cached_metadata) = state.metadata_cache.get(path).await { + debug!("Metadata found in cache for path={}", path); + return Ok(ImageInfo { + width: cached_metadata.width, + height: cached_metadata.height, + format: cached_metadata.format, + content_type: (!cached_metadata.content_type.is_empty()).then_some(cached_metadata.content_type), + size_bytes: cached_metadata.size_bytes, + channels: cached_metadata.channels, + has_alpha: cached_metadata.has_alpha, + orientation: (cached_metadata.orientation != 0).then_some(cached_metadata.orientation), + pages: cached_metadata.pages.max(1), + }); + } + + let decoded_url = url_parts.source_url.decode()?; + + let fetched = fetch_image(&state.http_client, &decoded_url, None).await?; + let image_bytes = fetched.bytes; + let content_type = fetched.content_type; + + let waiting = ImageOperationActivityGuard::waiting(ImageOperation::Info); + let semaphore_wait = ImageOperationTimer::start(ImageOperation::Info, ImageOperationPhase::SemaphoreWait); + let permit = state + .semaphore + .clone() + .acquire_owned() + .await + .map_err(|_| ServiceError::new(StatusCode::INTERNAL_SERVER_ERROR, "Semaphore closed"))?; + drop(semaphore_wait); + let info_content_type = content_type.clone(); + let span = tracing::Span::current(); + let blocking_queue = ImageOperationTimer::start(ImageOperation::Info, ImageOperationPhase::BlockingQueue); + let (metadata, cacheable) = tokio::task::spawn_blocking(move || { + drop(blocking_queue); + drop(waiting); + let _active = ImageOperationActivityGuard::active(ImageOperation::Info); + let _execution = ImageOperationTimer::start(ImageOperation::Info, ImageOperationPhase::Execution); + let _span_guard = span.enter(); + let _permit = permit; + let size_bytes = image_bytes.len(); + + // A multi-page source is opened whole so the reported page count is the + // real one rather than the single page the default load returns. + let load_options = match sniff_image_format(&image_bytes) { + Some(format) if crate::processing::animation::supports_pages(format) => "n=-1", + _ => "", + }; + + match VipsImage::new_from_buffer(&image_bytes, load_options) { + Ok(img) => { + let channels = img.get_bands() as u32; + ( + CachedMetadata { + width: img.get_width() as u32, + height: img.get_page_height().max(1) as u32, + format: detect_image_format(info_content_type.as_deref(), &image_bytes), + content_type: info_content_type.unwrap_or_default(), + size_bytes, + channels, + has_alpha: image_has_alpha(channels), + orientation: read_exif_orientation(&image_bytes).unwrap_or(0), + pages: img.get_n_pages().max(1) as u32, + }, + true, + ) + } + Err(err) => { + error!("Failed to decode image for info: {}", err); + ( + CachedMetadata { + format: "unknown".to_string(), + content_type: info_content_type.unwrap_or_default(), + size_bytes, + pages: 1, + ..CachedMetadata::default() + }, + false, + ) + } + } + }) + .await + .map_err(|source| ServiceError::BlockingTask { + operation: "image metadata", + source, + })?; + + if cacheable && !matches!(state.metadata_cache, MetadataCache::None) { + if let Err(err) = state.metadata_cache.insert(path.to_string(), metadata.clone()) { + error!("Failed to cache metadata: {}", err); + } + } + + info!( + "Imgforge info served path={} width={} height={} format={} size_bytes={} channels={} has_alpha={} pages={}", + path, + metadata.width, + metadata.height, + metadata.format, + metadata.size_bytes, + metadata.channels, + metadata.has_alpha, + metadata.pages + ); + + Ok(ImageInfo { + width: metadata.width, + height: metadata.height, + format: metadata.format, + content_type: (!metadata.content_type.is_empty()).then_some(metadata.content_type), + size_bytes: metadata.size_bytes, + channels: metadata.channels, + has_alpha: metadata.has_alpha, + orientation: (metadata.orientation != 0).then_some(metadata.orientation), + pages: metadata.pages.max(1), + }) +} + +fn parse_and_authorize(config: &Config, path: &str, bearer_token: Option<&str>) -> Result { + if let Some(secret) = config.secret.as_ref() { + if !secret.is_empty() { + match bearer_token { + Some(token) if token == secret => {} + Some(_) => { + error!("Invalid authorization token"); + return Err(ServiceError::new(StatusCode::FORBIDDEN, "Invalid authorization token")); + } + None => { + error!("Missing authorization token"); + return Err(ServiceError::new(StatusCode::FORBIDDEN, "Missing authorization token")); + } + } + } + } + + let url_parts = parse_path(path).ok_or_else(|| { + error!("Invalid URL format: {}", path); + ServiceError::new(StatusCode::BAD_REQUEST, "Invalid URL format") + })?; + + if url_parts.signature == "unsafe" { + if !config.allow_unsigned { + error!("Unsigned URLs are not allowed"); + return Err(ServiceError::new( + StatusCode::FORBIDDEN, + "Unsigned URLs are not allowed", + )); + } + } else { + let path_to_sign = build_path_to_sign(path).ok_or_else(|| { + error!("Invalid URL format: {}", path); + ServiceError::new(StatusCode::BAD_REQUEST, "Invalid URL format") + })?; + if !validate_signature(&config.key, &config.salt, &url_parts.signature, &path_to_sign) { + error!("Invalid signature for path: {}", path_to_sign); + return Err(ServiceError::new(StatusCode::FORBIDDEN, "Invalid signature")); + } + } + + Ok(url_parts) +} + +fn build_path_to_sign(path: &str) -> Option { + path.find('/').map(|idx| format!("/{}", &path[idx + 1..])) +} + +fn needs_watermark(parsed_options: &ParsedOptions) -> bool { + parsed_options.watermark.is_some() || parsed_options.watermark_url.is_some() +} + +async fn resolve_watermark( + state: &AppState, + parsed_options: &ParsedOptions, +) -> Result, ServiceError> { + if let Some(url) = &parsed_options.watermark_url { + debug!("Fetching watermark from URL: {}", url); + match fetch_image(&state.http_client, url, None).await { + Ok(fetched) => Ok(Some(CachedWatermark::from_bytes(fetched.bytes))), + Err(source) => Err(ServiceError::WatermarkFetch { source }), + } + } else if parsed_options.watermark.is_some() { + if let Some(path) = &state.config.watermark_path { + let watermark = + state + .watermark_cache + .get_or_try_init(|| async { + debug!("Loading watermark from path: {} (cached on first load)", path); + let bytes = fs::read(path).await.map(Bytes::from).map_err(|e| { + error!("Failed to read watermark image from path: {}", e); + ServiceError::new(StatusCode::BAD_REQUEST, "Failed to read watermark image from path") + })?; + + let waiting = ImageOperationActivityGuard::waiting(ImageOperation::Watermark); + let semaphore_wait = + ImageOperationTimer::start(ImageOperation::Watermark, ImageOperationPhase::SemaphoreWait); + let permit = + state.semaphore.clone().acquire_owned().await.map_err(|_| { + ServiceError::new(StatusCode::INTERNAL_SERVER_ERROR, "Semaphore closed") + })?; + drop(semaphore_wait); + let span = tracing::Span::current(); + let blocking_queue = + ImageOperationTimer::start(ImageOperation::Watermark, ImageOperationPhase::BlockingQueue); + tokio::task::spawn_blocking(move || { + drop(blocking_queue); + drop(waiting); + let _active = ImageOperationActivityGuard::active(ImageOperation::Watermark); + let _execution = + ImageOperationTimer::start(ImageOperation::Watermark, ImageOperationPhase::Execution); + let _span_guard = span.enter(); + let _permit = permit; + watermark::prepare_cached_watermark(bytes) + }) + .await + .map_err(|source| ServiceError::BlockingTask { + operation: "watermark preparation", + source, + })? + .map_err(ProcessingError::from) + .map_err(ServiceError::from) + }) + .await?; + Ok(Some(watermark.clone())) + } else { + Ok(None) + } + } else { + Ok(None) + } +} + +/// Returns the source bytes as they arrived, for `raw` and `skip_processing`. +async fn serve_source_response( + state: &AppState, + path: &str, + cache_key: &str, + image_bytes: Bytes, + source_content_type: Option, + content_disposition: Option, +) -> Result { + let content_type = source_content_type + .as_deref() + .map(format_to_content_type) + .unwrap_or("image/jpeg"); + + if content_disposition.is_none() && !matches!(state.cache, ImgforgeCache::None) { + if let Err(err) = state.cache.insert( + cache_key.to_string(), + CachedImage { + bytes: image_bytes.clone(), + content_type, + }, + ) { + error!("Failed to cache raw image: {}", err); + } + } + + info!("Imgforge served source path={} bytes={}", path, image_bytes.len()); + + Ok(ProcessedImage { + bytes: image_bytes, + content_type, + cache_status: CacheStatus::Miss, + content_disposition, + }) +} + +fn content_disposition_for(parsed_options: &ParsedOptions) -> Option { + let filename = parsed_options.filename.as_ref()?; + let disposition = if parsed_options.return_attachment { + "attachment" + } else { + "inline" + }; + Some(format!( + "{}; filename=\"{}\"", + disposition, + filename.replace(['\\', '"', '\r', '\n'], "_") + )) +} + +#[cfg(test)] +mod tests; diff --git a/src/service/security.rs b/src/service/security.rs new file mode 100644 index 0000000..b87da1f --- /dev/null +++ b/src/service/security.rs @@ -0,0 +1,167 @@ +//! Request-level limits. +//! +//! Every limit has two possible sources: the server's configuration and the URL +//! itself. The URL only wins when the server opted in with +//! `IMGFORGE_ALLOW_SECURITY_OPTIONS`, so a public deployment cannot have its +//! ceilings raised by whoever is composing the URLs. + +use super::error::ServiceError; +use crate::app::AppState; +use crate::config::Config; +use crate::limits::{ + MaxAnimationFrameResolution, MaxAnimationFrames, MaxResultDimension, MaxSourceFileSize, MaxSourceResolution, +}; +use crate::processing::options::ParsedOptions; +use axum::http::StatusCode; +use bytes::Bytes; +use libvips::VipsImage; +use std::time::{SystemTime, UNIX_EPOCH}; +use tracing::{debug, error}; + +/// Builds the accessor pair for one limit: request override when allowed, +/// configuration otherwise. +macro_rules! resolve_limit { + ($name:ident, $ty:ty, $field:ident) => { + pub fn $name(config: &Config, parsed_options: &ParsedOptions) -> Option<$ty> { + if config.allow_security_options { + parsed_options.$field.or(config.$field) + } else { + config.$field + } + } + }; +} + +resolve_limit!(resolve_max_src_file_size, MaxSourceFileSize, max_src_file_size); +resolve_limit!(resolve_max_src_resolution, MaxSourceResolution, max_src_resolution); +resolve_limit!(resolve_max_result_dimension, MaxResultDimension, max_result_dimension); +resolve_limit!(resolve_max_animation_frames, MaxAnimationFrames, max_animation_frames); +resolve_limit!( + resolve_max_animation_frame_resolution, + MaxAnimationFrameResolution, + max_animation_frame_resolution +); + +/// Copies the effective limits back onto the parsed options. +/// +/// The pipeline enforces the result and per-frame ceilings itself, deep inside +/// the blocking task, and it only ever sees `ParsedOptions`. Folding the +/// configured values in here means there is one resolution rule rather than a +/// second copy of it further down. +pub fn apply_effective_limits(config: &Config, parsed_options: &mut ParsedOptions) { + parsed_options.max_result_dimension = resolve_max_result_dimension(config, parsed_options); + parsed_options.max_animation_frames = resolve_max_animation_frames(config, parsed_options); + parsed_options.max_animation_frame_resolution = resolve_max_animation_frame_resolution(config, parsed_options); +} + +/// The same checks, for a response that hands back the source untouched. +/// +/// `raw` and `skip_processing` never build a pipeline, so the decoded image the +/// resolution check wants does not exist yet. Only that one check needs it, and +/// only when a limit is configured, so the header is read on demand rather than +/// for every passthrough — opening a buffer parses the header and decodes +/// nothing, which is what makes paying for it here cheap enough to be +/// unconditional. +pub fn enforce_source_constraints( + state: &AppState, + parsed_options: &ParsedOptions, + image_bytes: &Bytes, + source_content_type: Option<&str>, +) -> Result<(), ServiceError> { + let decoded = resolve_max_src_resolution(&state.config, parsed_options) + .map(|_| VipsImage::new_from_buffer(image_bytes, "")) + .transpose() + .map_err(|source| ServiceError::SourceImageDecode { source })?; + + enforce_security_constraints( + state, + parsed_options, + image_bytes, + source_content_type, + decoded.as_ref(), + ) +} + +pub fn enforce_security_constraints( + state: &AppState, + parsed_options: &ParsedOptions, + image_bytes: &Bytes, + source_content_type: Option<&str>, + decoded_image: Option<&VipsImage>, +) -> Result<(), ServiceError> { + let config = &state.config; + + if let Some(max_size) = resolve_max_src_file_size(config, parsed_options) { + if image_bytes.len() > max_size.get() { + error!("Source image file size is too large"); + return Err(ServiceError::new( + StatusCode::BAD_REQUEST, + "Source image file size is too large", + )); + } + } + + if let Some(allowed_types) = &config.allowed_mime_types { + if let Some(content_type) = source_content_type { + if !allowed_types.iter().any(|allowed| allowed == content_type) { + error!("Source image MIME type is not allowed: {}", content_type); + return Err(ServiceError::new( + StatusCode::BAD_REQUEST, + "Source image MIME type is not allowed", + )); + } + } + } + + if let Some(max_res) = resolve_max_src_resolution(config, parsed_options) { + let (w, h) = match decoded_image { + Some(img) => (img.get_width(), img.get_height()), + None => { + error!("Failed to load image for resolution check"); + return Err(ServiceError::new( + StatusCode::BAD_REQUEST, + "Failed to load image for resolution check", + )); + } + }; + debug!("Image resolution: {}x{}", w, h); + let source_pixels = checked_source_pixel_count(w, h)?; + if source_pixels > max_res.pixels() { + error!("Source image resolution is too large"); + return Err(ServiceError::new( + StatusCode::BAD_REQUEST, + "Source image resolution is too large", + )); + } + } + + Ok(()) +} + +pub fn checked_source_pixel_count(width: i32, height: i32) -> Result { + let width = u64::try_from(width) + .map_err(|_| ServiceError::new(StatusCode::BAD_REQUEST, "Invalid source image dimensions"))?; + let height = u64::try_from(height) + .map_err(|_| ServiceError::new(StatusCode::BAD_REQUEST, "Invalid source image dimensions"))?; + + width + .checked_mul(height) + .ok_or_else(|| ServiceError::new(StatusCode::BAD_REQUEST, "Source image resolution is too large")) +} + +pub fn enforce_expiration(parsed_options: &ParsedOptions) -> Result<(), ServiceError> { + let Some(expires) = parsed_options.expires else { + return Ok(()); + }; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| ServiceError::new(StatusCode::INTERNAL_SERVER_ERROR, "system clock is before unix epoch"))? + .as_secs(); + + if now > expires { + return Err(ServiceError::new(StatusCode::NOT_FOUND, "URL has expired")); + } + + Ok(()) +} diff --git a/src/service/source.rs b/src/service/source.rs new file mode 100644 index 0000000..4cf7abe --- /dev/null +++ b/src/service/source.rs @@ -0,0 +1,304 @@ +//! Identifying the source image and deciding how to open it. + +use crate::config::DefaultOutputFormat; +use crate::processing::animation::LoadPlan; +use crate::processing::options::{Gravity, GravityType, ParsedOptions}; +use crate::processing::{load_scale_factor, load_shrink_factor}; +use crate::utils::content_type_to_format; +use bytes::Bytes; +use libvips::VipsImage; +use tracing::debug; + +/// Identifies a format from its magic bytes. +/// +/// The `Content-Type` a server advertises is frequently wrong or absent, and +/// every decision downstream — which loader options are legal, whether the +/// source can be passed through untouched — depends on knowing what the bytes +/// actually are. +pub fn sniff_image_format(image_bytes: &[u8]) -> Option<&'static str> { + if image_bytes.len() >= 3 && image_bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + return Some("jpeg"); + } + + if image_bytes.len() >= 8 && image_bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return Some("png"); + } + + if image_bytes.len() >= 6 && (image_bytes.starts_with(b"GIF87a") || image_bytes.starts_with(b"GIF89a")) { + return Some("gif"); + } + + if image_bytes.len() >= 12 && image_bytes.starts_with(b"RIFF") && &image_bytes[8..12] == b"WEBP" { + return Some("webp"); + } + + if image_bytes.len() >= 4 && (image_bytes.starts_with(b"II*\0") || image_bytes.starts_with(b"MM\0*")) { + return Some("tiff"); + } + + if image_bytes.len() >= 5 && image_bytes.starts_with(b"%PDF-") { + return Some("pdf"); + } + + if image_bytes.len() >= 12 + && image_bytes[4..8] == *b"ftyp" + && matches!( + &image_bytes[8..12], + b"avif" | b"avis" | b"heic" | b"heix" | b"hevc" | b"hevx" | b"mif1" | b"msf1" + ) + { + let brand = &image_bytes[8..12]; + return Some(if brand == b"avif" || brand == b"avis" { + "avif" + } else { + "heif" + }); + } + + // SVG has no magic number, only a root element that may sit behind an XML + // declaration, a doctype, or a byte-order mark. + if looks_like_svg(image_bytes) { + return Some("svg"); + } + + None +} + +fn looks_like_svg(image_bytes: &[u8]) -> bool { + let prefix = &image_bytes[..image_bytes.len().min(1024)]; + let text = String::from_utf8_lossy(prefix); + let trimmed = text.trim_start_matches('\u{feff}').trim_start(); + (trimmed.starts_with(", image_bytes: &[u8]) -> String { + if let Some(format) = content_type.and_then(content_type_to_format) { + return format.to_string(); + } + + sniff_image_format(image_bytes).unwrap_or("unknown").to_string() +} + +/// The loader option string for a source, or an empty string for the defaults. +pub fn loader_options(options: &ParsedOptions, source_format: Option<&str>, output_format: &str) -> String { + LoadPlan::resolve(options, source_format, output_format) + .map(|plan| plan.as_load_options()) + .unwrap_or_default() +} + +/// Whether the request may be answered with the source bytes as they arrived. +/// +/// `skip_processing` names source formats to leave alone. imgproxy applies it +/// only when the output format matches the source, because a request that also +/// asks for a different format is asking for work that cannot be skipped. +pub fn can_skip_processing( + options: &ParsedOptions, + source_format: Option<&str>, + default_format: DefaultOutputFormat, +) -> bool { + if options.skip_processing.is_empty() { + return false; + } + let Some(source_format) = source_format else { + return false; + }; + + // Compared through the one alias table rather than a copy of it. This used + // to spell out jpg/jpeg and heic/heif by hand and simply omit tif/tiff, so + // `skip_processing:tif` never matched a TIFF source — a second table that + // had drifted from the real one. Routing through `canonical_format_name` + // means a format added there is understood here for free. + let canonical = crate::processing::save::canonical_format_name; + let matches_source = |candidate: &str| match (canonical(candidate), canonical(source_format)) { + (Some(candidate), Some(source)) => candidate == source, + // A name imgforge cannot encode can still be compared to itself, which + // keeps an unrecognised `skip_processing` entry behaving predictably + // rather than matching everything or nothing. + _ => candidate.eq_ignore_ascii_case(source_format), + }; + + if !options.skip_processing.iter().any(|listed| matches_source(listed)) { + return false; + } + + // A requested format that differs from the source is a conversion, and a + // conversion is processing. The URL is not the only thing that can request + // one: a fixed `IMGFORGE_DEFAULT_FORMAT` says every response is that format, + // so a URL naming none is still asking for a conversion. Reading an absent + // URL format as "same as the source" let `skip_processing:jpeg` hand back a + // JPEG from a deployment configured to serve only WebP. + match options.format.as_deref().or_else(|| default_format.fixed_format()) { + Some(requested) => matches_source(requested), + None => true, + } +} + +/// Whether EXIF orientation will transpose the image during processing. +pub fn swaps_axes(parsed_options: &ParsedOptions, image_bytes: &Bytes) -> bool { + parsed_options.auto_rotate + && matches!( + crate::utils::read_exif_orientation(image_bytes), + Some(5) | Some(6) | Some(7) | Some(8) + ) +} + +/// Rewrites an absolute crop region to match a source decoded at a reduced size. +/// +/// Rounds the region up: it is clamped to the image by the crop itself anyway, +/// and rounding down could leave it fractionally smaller than the resize target, +/// which `enlarge:false` would then refuse to make up. +/// +/// Each axis is decided on its own. A fractional extent is already measured +/// against whatever was decoded and needs no rewriting, but that is a property +/// of the axis rather than of the crop: `crop:0.5:1000` mixes the two, and +/// exempting the whole crop because one axis was fractional left the absolute +/// one addressing full-resolution coordinates. +pub fn rescale_crop(parsed_options: &mut ParsedOptions, original: (i32, i32), shrunk: (i32, i32)) { + let Some(crop) = parsed_options.crop.as_mut() else { + return; + }; + + let (ow, oh) = (f64::from(original.0), f64::from(original.1)); + let (sw, sh) = (f64::from(shrunk.0), f64::from(shrunk.1)); + if ow <= 0.0 || oh <= 0.0 || sw <= 0.0 || sh <= 0.0 { + return; + } + + // A zero extent already means "all of it" and stays that way. + if crop.width >= 1.0 { + crop.width = (crop.width * (sw / ow)).ceil().max(1.0); + } + if crop.height >= 1.0 { + crop.height = (crop.height * (sh / oh)).ceil().max(1.0); + } + + // The window's *position* is measured in the same pixels as its size, so an + // absolute gravity offset has to move with it. Rewriting only the extents + // left a `crop:...:nowe:400:0` against a 4x shrink offsetting by 400px of a + // quarter-size image — four times too far, selecting the wrong region of the + // source entirely. + // + // The scaled offsets are written into the crop's own gravity rather than the + // request's. `crop_gravity` falls back to `gravity` when the crop names + // none, but `fill_gravity` reads that same field to position the *resized* + // image, which is not measured in source pixels at all — scaling it in place + // would fix the crop by breaking the fill. + let fallback = parsed_options.gravity; + if let Some(gravity) = crop.gravity.or(fallback) { + crop.gravity = Some(rescale_gravity(gravity, sw / ow, sh / oh)); + } +} + +/// Rewrites a gravity's absolute offsets for a source decoded at a reduced size. +/// +/// Only offsets of magnitude 1 or more are pixel counts; anything smaller is a +/// fraction of the axis and already scales itself. A focus point reads its two +/// arguments as coordinates in 0..1, so it is a fraction throughout and must be +/// left exactly as it is. +fn rescale_gravity(gravity: Gravity, x_scale: f64, y_scale: f64) -> Gravity { + if gravity.kind == GravityType::FocusPoint { + return gravity; + } + + let scaled = |offset: f64, scale: f64| { + if offset.abs() >= 1.0 { + offset * scale + } else { + offset + } + }; + + Gravity { + kind: gravity.kind, + x: scaled(gravity.x, x_scale), + y: scaled(gravity.y, y_scale), + } +} + +/// Reopens the source at a reduced scale when the plan allows it, falling back +/// to the image already opened. +/// +/// Only JPEG and WebP: their loaders genuinely skip the work, JPEG through a +/// power-of-two `shrink` and WebP through a continuous `scale`. Other loaders +/// either have no equivalent or spell it differently, and naming a property a +/// loader does not have makes libvips reject the whole call. +pub fn shrink_source_on_load( + source_image: VipsImage, + image_bytes: &Bytes, + parsed_options: &mut ParsedOptions, + base_load_options: &str, +) -> VipsImage { + let format = sniff_image_format(image_bytes); + if !matches!(format, Some("jpeg") | Some("webp")) { + return source_image; + } + + let (width, height) = (source_image.get_width(), source_image.get_height()); + let (Ok(width), Ok(height)) = (u32::try_from(width), u32::try_from(height)) else { + return source_image; + }; + + // EXIF orientations 5-8 swap the axes, and the rotation happens after the + // load. The plan is written against what the viewer sees, so the factor has + // to be chosen against the rotated dimensions, not the stored ones. + let (width, height) = if swaps_axes(parsed_options, image_bytes) { + (height, width) + } else { + (width, height) + }; + + // JPEG takes a power-of-two shrink; WebP takes a continuous scale and can + // therefore decode much closer to what is actually needed. + let load_option = if format == Some("webp") { + load_scale_factor(parsed_options, width, height).map(|scale| format!("scale={scale}")) + } else { + match load_shrink_factor(parsed_options, width, height) { + factor if factor > 1 => Some(format!("shrink={factor}")), + _ => None, + } + }; + let Some(load_option) = load_option else { + return source_image; + }; + + let load_options = if base_load_options.is_empty() { + load_option + } else { + format!("{base_load_options},{load_option}") + }; + + match VipsImage::new_from_buffer(image_bytes, &load_options) { + Ok(shrunk) => { + debug!( + "Decoding with {}: {}x{} -> {}x{}", + load_options, + width, + height, + shrunk.get_width(), + shrunk.get_height() + ); + // A crop names a region of the source in pixels, and the source + // just got smaller. Rewrite it against what was actually decoded + // rather than the requested factor, so rounding in the loader + // cannot leave the region pointing at the wrong pixels. + rescale_crop( + parsed_options, + (source_image.get_width(), source_image.get_height()), + (shrunk.get_width(), shrunk.get_height()), + ); + shrunk + } + // Nothing is lost by carrying on with the image already opened. + Err(err) => { + debug!("Shrink-on-load unavailable, using the full-size source: {}", err); + source_image + } + } +} + +/// Whether a channel count implies an alpha channel. +pub fn image_has_alpha(channels: u32) -> bool { + matches!(channels, 2 | 4) +} diff --git a/src/service/tests.rs b/src/service/tests.rs new file mode 100644 index 0000000..6b5f357 --- /dev/null +++ b/src/service/tests.rs @@ -0,0 +1,976 @@ +//! Service-layer tests: cache identity, limit resolution, and the mapping from +//! internal failures to what a client is told. + +use super::*; +use crate::config::Config; +use crate::fetch::FetchError; +use crate::limits::MaxResultDimension; +use crate::processing::animation::LoadPlan; +use crate::processing::options::{Crop, OptionDefaults}; +use crate::processing::save::SaveError; +use crate::processing::transform::TransformError; +use crate::service::cache_key::processed_cache_key; +use crate::service::security::{checked_source_pixel_count, resolve_max_result_dimension}; +use crate::service::source::{rescale_crop, swaps_axes}; +use std::error::Error as _; + +fn key_parts(path: &str) -> CacheKeyParts<'_> { + CacheKeyParts { + path, + default_format: DefaultOutputFormat::Source, + has_explicit_format: false, + is_raw: false, + max_result_dimension: None, + max_animation_frames: None, + max_animation_frame_resolution: None, + max_src_resolution: None, + max_src_file_size: None, + allowed_mime_types: None, + watermark_path: None, + option_defaults: None, + negotiated_format: None, + } +} + +#[test] +fn cache_keys_are_namespaced_by_the_effective_result_limit() { + let path = "/unsafe/resize:fit:4000:4000/example"; + let unlimited = processed_cache_key(key_parts(path)); + let limited = processed_cache_key(CacheKeyParts { + max_result_dimension: Some("1000".parse().unwrap()), + ..key_parts(path) + }); + let raised = processed_cache_key(CacheKeyParts { + max_result_dimension: Some("8192".parse().unwrap()), + ..key_parts(path) + }); + + // A disk cache outlives the config that filled it. Entries stored under + // one ceiling must not be served under another, or a request that the + // limit should refuse comes straight back out of the cache. + assert_ne!(unlimited, limited); + assert_ne!(limited, raised); + + // Turning the feature on must not invalidate caches that never use it. + assert_eq!(unlimited, processed_cache_key(key_parts(path))); +} + +/// Every ceiling that can reject a response has to reach the key, not just the +/// result dimension. Tightening an animation limit while a persistent cache +/// still holds the looser result would otherwise be answered from the cache +/// before the limit ever ran. +#[test] +fn cache_keys_are_namespaced_by_the_effective_animation_limits() { + let path = "/unsafe/resize:fit:800:600/example.gif"; + + let unlimited = processed_cache_key(key_parts(path)); + let few_frames = processed_cache_key(CacheKeyParts { + max_animation_frames: Some("10".parse().unwrap()), + ..key_parts(path) + }); + let more_frames = processed_cache_key(CacheKeyParts { + max_animation_frames: Some("50".parse().unwrap()), + ..key_parts(path) + }); + assert_ne!(unlimited, few_frames); + assert_ne!(few_frames, more_frames); + + let small_frames = processed_cache_key(CacheKeyParts { + max_animation_frame_resolution: Some("1.0".parse().unwrap()), + ..key_parts(path) + }); + let large_frames = processed_cache_key(CacheKeyParts { + max_animation_frame_resolution: Some("9.0".parse().unwrap()), + ..key_parts(path) + }); + assert_ne!(unlimited, small_frames); + assert_ne!(small_frames, large_frames); + + // The two limits are independent, so setting one must not collide with the + // other's namespace. + assert_ne!(few_frames, small_frames); + + // Deployments that configure neither keep the keys they already have. + assert_eq!(unlimited, processed_cache_key(key_parts(path))); +} + +#[test] +fn negotiated_formats_get_their_own_cache_entries() { + let path = "/unsafe/resize:fit:100:100/example"; + + let plain = processed_cache_key(key_parts(path)); + let webp = processed_cache_key(CacheKeyParts { + negotiated_format: Some("webp"), + ..key_parts(path) + }); + let avif = processed_cache_key(CacheKeyParts { + negotiated_format: Some("avif"), + ..key_parts(path) + }); + + // One URL now produces different bytes for different clients. Sharing an + // entry between them would hand a client a format its Accept header said + // it could not read. + assert_ne!(plain, webp); + assert_ne!(webp, avif); + + // An explicit format in the URL is not negotiable, so it keeps the bare + // path and stays shared across clients. + let explicit = processed_cache_key(CacheKeyParts { + has_explicit_format: true, + negotiated_format: Some("webp"), + ..key_parts("/unsafe/format:png/example") + }); + assert_eq!(explicit, "/unsafe/format:png/example"); +} + +/// A real JPEG with an APP1/Exif segment carrying just the Orientation tag, +/// spliced in after SOI. Built by hand so the fixture needs no tooling and +/// no checked-in binary. +fn exif_orientation_jpeg(orientation: u16) -> Vec { + use image::{ImageBuffer, ImageFormat, Rgb}; + + let mut base = Vec::new(); + ImageBuffer::, Vec>::from_pixel(8, 4, Rgb([10, 20, 30])) + .write_to(&mut std::io::Cursor::new(&mut base), ImageFormat::Jpeg) + .unwrap(); + + let mut tiff = Vec::new(); + tiff.extend_from_slice(b"II"); // little-endian + tiff.extend_from_slice(&42u16.to_le_bytes()); + tiff.extend_from_slice(&8u32.to_le_bytes()); // IFD0 starts here + tiff.extend_from_slice(&1u16.to_le_bytes()); // one entry + tiff.extend_from_slice(&0x0112u16.to_le_bytes()); // Orientation + tiff.extend_from_slice(&3u16.to_le_bytes()); // SHORT + tiff.extend_from_slice(&1u32.to_le_bytes()); // count + tiff.extend_from_slice(&orientation.to_le_bytes()); + tiff.extend_from_slice(&0u16.to_le_bytes()); // value field is 4 bytes wide + tiff.extend_from_slice(&0u32.to_le_bytes()); // no further IFD + + let mut app1 = Vec::from(&b"Exif\0\0"[..]); + app1.extend_from_slice(&tiff); + + let mut out = Vec::new(); + out.extend_from_slice(&base[..2]); // SOI + out.extend_from_slice(&[0xFF, 0xE1]); + out.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&app1); + out.extend_from_slice(&base[2..]); + out +} + +#[test] +fn crop_regions_are_rewritten_for_a_reduced_decode() { + // A crop names source pixels, so a source decoded at a quarter size + // needs the region quartered with it. Exercised through the function + // the request path actually calls, not a hand-rolled equivalent. + let original = (2000, 1600); + let shrunk = (500, 400); + + let mut options = ParsedOptions { + crop: Some(Crop { + width: 1000.0, + height: 800.0, + gravity: None, + }), + ..ParsedOptions::default() + }; + rescale_crop(&mut options, original, shrunk); + let crop = options.crop.unwrap(); + assert_eq!((crop.width, crop.height), (250.0, 200.0)); + + // A zero extent already means "all of it" and must stay that way, + // otherwise it would be pinned to one pixel. + let mut options = ParsedOptions { + crop: Some(Crop { + width: 0.0, + height: 800.0, + gravity: None, + }), + ..ParsedOptions::default() + }; + rescale_crop(&mut options, original, shrunk); + let crop = options.crop.unwrap(); + assert_eq!((crop.width, crop.height), (0.0, 200.0)); + + // Rounding up: a region that does not divide evenly must not come back + // smaller than the resize target needs. + let mut options = ParsedOptions { + crop: Some(Crop { + width: 999.0, + height: 3.0, + gravity: None, + }), + ..ParsedOptions::default() + }; + rescale_crop(&mut options, original, shrunk); + let crop = options.crop.unwrap(); + assert_eq!((crop.width, crop.height), (250.0, 1.0)); +} + +/// A crop that mixes a fraction with an absolute extent has to be decided per +/// axis. Skipping both because one was fractional left the absolute extent +/// addressing coordinates in a source that had already been decoded smaller. +#[test] +fn a_mixed_crop_rescales_only_its_absolute_axis() { + let mut options = ParsedOptions { + crop: Some(Crop { + width: 0.5, + height: 1000.0, + gravity: None, + }), + ..ParsedOptions::default() + }; + rescale_crop(&mut options, (2000, 1600), (500, 400)); + + let crop = options.crop.unwrap(); + assert_eq!(crop.width, 0.5, "a fraction is already relative to what was decoded"); + assert_eq!(crop.height, 250.0, "an absolute extent has to follow the decode"); +} + +#[test] +fn a_fractional_crop_survives_a_reduced_decode_untouched() { + // A crop expressed as a fraction is measured against whatever was decoded, + // so rescaling it would shrink it twice. + let mut options = ParsedOptions { + crop: Some(Crop { + width: 0.5, + height: 0.5, + gravity: None, + }), + ..ParsedOptions::default() + }; + rescale_crop(&mut options, (2000, 1600), (500, 400)); + let crop = options.crop.unwrap(); + assert_eq!((crop.width, crop.height), (0.5, 0.5)); +} + +#[test] +fn axis_swapping_orientations_are_recognised() { + // The wiring that feeds load_shrink_factor its dimensions. Without this, + // a transposed source is measured on its stored shape and over-shrunk: + // the factor test proves the arithmetic, this proves it is reached. + let rotating = ParsedOptions { + auto_rotate: true, + ..ParsedOptions::default() + }; + let fixed = ParsedOptions { + auto_rotate: false, + ..ParsedOptions::default() + }; + + // A JPEG carrying orientation 6, which transposes the image. + let rotated_jpeg = Bytes::from(exif_orientation_jpeg(6)); + assert!( + swaps_axes(&rotating, &rotated_jpeg), + "orientation 6 transposes and must swap the axes" + ); + assert!( + !swaps_axes(&fixed, &rotated_jpeg), + "auto_rotate:false leaves the stored shape alone" + ); + + // Orientation 3 is a 180 rotation: same shape, no swap. + let upright_jpeg = Bytes::from(exif_orientation_jpeg(3)); + assert!(!swaps_axes(&rotating, &upright_jpeg)); + + // No EXIF at all. + assert!(!swaps_axes(&rotating, &Bytes::from_static(b"not an image"))); +} + +#[test] +fn raw_cache_keys_ignore_the_result_limit() { + // The raw path inserts under the bare path, so the lookup key has to match + // it exactly or raw requests miss the cache forever and refetch the source + // every time. + let path = "/unsafe/raw/example"; + let limit = Some("1000".parse::().unwrap()); + + assert_eq!( + processed_cache_key(CacheKeyParts { + is_raw: true, + max_result_dimension: limit, + ..key_parts(path) + }), + path + ); + assert_eq!( + processed_cache_key(CacheKeyParts { + is_raw: true, + ..key_parts(path) + }), + path + ); +} + +#[test] +fn max_result_dimension_override_requires_security_options() { + let request_limit = "1000".parse::().unwrap(); + let server_limit = "4000".parse::().unwrap(); + + let parsed_options = ParsedOptions { + max_result_dimension: Some(request_limit), + ..ParsedOptions::default() + }; + + let mut config = Config::new(vec![0u8; 32], vec![0u8; 32]); + config.max_result_dimension = Some(server_limit); + + // Locked down: the URL cannot set its own ceiling, so the server's stands. + config.allow_security_options = false; + assert_eq!( + resolve_max_result_dimension(&config, &parsed_options), + Some(server_limit) + ); + + // Opted in: the request wins, matching how max_src_* already behave. + config.allow_security_options = true; + assert_eq!( + resolve_max_result_dimension(&config, &parsed_options), + Some(request_limit) + ); + + // No server limit and no opt-in means no ceiling at all. + config.allow_security_options = false; + config.max_result_dimension = None; + assert_eq!(resolve_max_result_dimension(&config, &parsed_options), None); +} + +#[test] +fn skip_processing_only_applies_when_the_output_matches_the_source() { + let listed = |formats: &[&str], requested: Option<&str>| ParsedOptions { + skip_processing: formats.iter().map(|f| f.to_string()).collect(), + format: requested.map(str::to_string), + ..ParsedOptions::default() + }; + + assert!(can_skip_processing( + &listed(&["png"], None), + Some("png"), + DefaultOutputFormat::Source + )); + assert!(can_skip_processing( + &listed(&["png"], Some("png")), + Some("png"), + DefaultOutputFormat::Source + )); + // jpg and jpeg name the same format. + assert!(can_skip_processing( + &listed(&["jpg"], None), + Some("jpeg"), + DefaultOutputFormat::Source + )); + + // A conversion is processing, however the source is listed. + assert!(!can_skip_processing( + &listed(&["png"], Some("webp")), + Some("png"), + DefaultOutputFormat::Source + )); + // A format that was not listed is processed as usual. + assert!(!can_skip_processing( + &listed(&["png"], None), + Some("jpeg"), + DefaultOutputFormat::Source + )); + // Nothing listed means nothing skipped. + assert!(!can_skip_processing( + &ParsedOptions::default(), + Some("png"), + DefaultOutputFormat::Source + )); +} + +#[test] +fn multi_page_load_plans_are_only_built_when_they_change_something() { + // The default load already reads one page, so a still-image request must + // produce no loader options at all — passing `n` to a loader that has no + // such property makes libvips reject the whole call. + let defaults = ParsedOptions::default(); + assert_eq!(LoadPlan::resolve(&defaults, Some("jpeg"), "jpeg"), None); + assert_eq!(LoadPlan::resolve(&defaults, Some("gif"), "jpeg"), None); + + // An animated source into an animated output reads every frame. + let plan = LoadPlan::resolve(&defaults, Some("gif"), "gif").expect("animation is read whole"); + assert_eq!(plan.as_load_options(), "page=0,n=-1"); + + // Explicitly disabling animation collapses it to one frame again. + let still = ParsedOptions { + disable_animation: true, + ..ParsedOptions::default() + }; + assert_eq!(LoadPlan::resolve(&still, Some("gif"), "gif"), None); + + // page/pages address a multi-page document. + let paged = ParsedOptions { + page: Some(2), + pages: Some(3), + ..ParsedOptions::default() + }; + let plan = LoadPlan::resolve(&paged, Some("pdf"), "png").expect("pages are requested"); + assert_eq!(plan.as_load_options(), "page=2,n=3"); +} + +#[test] +fn the_animation_frame_limit_caps_what_is_decoded() { + let options = ParsedOptions { + max_animation_frames: Some("4".parse().unwrap()), + ..ParsedOptions::default() + }; + + let plan = LoadPlan::resolve(&options, Some("gif"), "gif").expect("a limit always produces a plan"); + assert_eq!(plan.as_load_options(), "page=0,n=4"); + + // A request for more frames than the limit allows is cut down to it. + let options = ParsedOptions { + pages: Some(100), + max_animation_frames: Some("4".parse().unwrap()), + ..ParsedOptions::default() + }; + let plan = LoadPlan::resolve(&options, Some("gif"), "gif").expect("a limit always produces a plan"); + assert_eq!(plan.as_load_options(), "page=0,n=4"); +} + +#[test] +fn configured_option_defaults_seed_the_parse() { + use crate::processing::options::{parse_all_options_with_defaults, ProcessingOption}; + + let defaults = OptionDefaults { + auto_rotate: false, + strip_metadata: true, + enforce_thumbnail: true, + quality: Some(60), + ..OptionDefaults::default() + }; + + let parsed = parse_all_options_with_defaults(Vec::new(), defaults).expect("defaults parse"); + assert!(!parsed.auto_rotate); + assert_eq!(parsed.save.strip_metadata, Some(true)); + assert!(parsed.enforce_thumbnail); + assert_eq!(parsed.quality, Some(60)); + + // The URL always wins, because it is applied on top of the defaults. + let overridden = parse_all_options_with_defaults( + vec![ + ProcessingOption { + name: "auto_rotate".to_string(), + args: vec!["1".to_string()], + }, + ProcessingOption { + name: "strip_metadata".to_string(), + args: vec!["0".to_string()], + }, + ProcessingOption { + name: "quality".to_string(), + args: vec!["90".to_string()], + }, + ], + defaults, + ) + .expect("overrides parse"); + assert!(overridden.auto_rotate); + assert_eq!(overridden.save.strip_metadata, Some(false)); + assert_eq!(overridden.quality, Some(90)); +} + +#[test] +fn fetch_size_error_has_centralized_http_mapping() { + let error = ServiceError::from(FetchError::SourceTooLarge { + limit: 1024, + actual: Some(2048), + }); + + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + assert_eq!( + error.message(), + "Source image exceeds the maximum allowed size of 1024 bytes" + ); + assert!(matches!( + error, + ServiceError::Fetch(FetchError::SourceTooLarge { + limit: 1024, + actual: Some(2048) + }) + )); +} + +#[tokio::test] +async fn fetch_request_error_does_not_expose_network_details() { + let source = reqwest::Client::new() + .get("not_a_valid_url") + .send() + .await + .expect_err("invalid URL should fail"); + let error = ServiceError::from(FetchError::Request(source)); + + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + assert_eq!(error.message(), "Error fetching image"); + assert!(error.source().is_some()); +} + +#[tokio::test] +async fn blocking_task_failure_maps_to_internal_server_error() { + let source = tokio::task::spawn_blocking(|| panic!("test blocking-task panic")) + .await + .expect_err("panicking task should return a join error"); + let error = ServiceError::BlockingTask { + operation: "test image operation", + source, + }; + + assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(error.message(), "Image operation failed"); + assert!(error.source().is_some()); +} + +#[test] +fn option_parse_error_has_centralized_http_mapping() { + use crate::processing::options::OptionParseError; + + let error = ServiceError::from(OptionParseError::InvalidValue( + "quality option requires one argument".to_string(), + )); + + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + assert_eq!(error.message(), "quality option requires one argument"); +} + +#[test] +fn source_url_error_uses_safe_client_message() { + use base64::Engine as _; + + let source = crate::url::SourceUrlInfo::Base64 { + encoded_url: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0xff]), + } + .decode() + .expect_err("invalid UTF-8 should fail"); + let error = ServiceError::from(source); + + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + assert_eq!(error.message(), "Error decoding URL"); + assert!(error.source().is_some()); +} + +#[test] +fn processing_error_preserves_vips_source_and_uses_safe_client_message() { + let transform_error = TransformError::Vips { + operation: "test resize", + source: libvips::error::Error::ResizeError, + }; + let error = ServiceError::from(ProcessingError::from(transform_error)); + + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + assert_eq!(error.message(), "Error processing image"); + assert!(error.source().is_some()); +} + +#[test] +fn encoder_failure_maps_to_internal_server_error() { + let save_error = SaveError::Vips { + format: "jpeg", + source: libvips::error::Error::JpegsaveBufferError, + }; + let error = ServiceError::from(ProcessingError::from(save_error)); + + assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(error.message(), "Failed to encode image"); + assert!(error.source().is_some()); +} + +#[test] +fn an_upstream_failure_is_reported_as_such() { + // A 404 from the origin used to reach the caller as "failed to decode + // source image", which pointed at the wrong thing entirely. + let error = ServiceError::from(FetchError::UpstreamStatus { status: 404 }); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + assert_eq!(error.message(), "Source responded with status 404"); +} + +#[test] +fn pixel_count_does_not_overflow_i32_sized_dimensions() { + assert_eq!(checked_source_pixel_count(50_000, 50_000).unwrap(), 2_500_000_000); +} + +#[test] +fn pixel_count_rejects_negative_dimensions() { + assert!(checked_source_pixel_count(-1, 100).is_err()); + assert!(checked_source_pixel_count(100, -1).is_err()); +} + +#[test] +fn fixed_default_format_is_resolved_without_sniffing() { + assert_eq!( + default_output_format(DefaultOutputFormat::Jpeg, b"not an image"), + Some("jpeg") + ); + assert_eq!( + default_output_format(DefaultOutputFormat::Heif, b"not an image"), + Some("heif") + ); +} + +#[test] +fn implicit_format_cache_keys_include_the_configured_default() { + let source_key = processed_cache_key(key_parts("/unsafe/example")); + let jpeg_key = processed_cache_key(CacheKeyParts { + default_format: DefaultOutputFormat::Jpeg, + ..key_parts("/unsafe/example") + }); + let explicit_key = processed_cache_key(CacheKeyParts { + default_format: DefaultOutputFormat::Jpeg, + has_explicit_format: true, + ..key_parts("/unsafe/format:png/example") + }); + + assert_ne!(source_key, jpeg_key); + assert_eq!(explicit_key, "/unsafe/format:png/example"); +} + +/// A crop's position is measured in the same pixels as its size, so a reduced +/// decode has to move the gravity offset with the extents. Rewriting only the +/// extents left an absolute offset pointing four times too far into a source +/// decoded at a quarter size. +#[test] +fn crop_gravity_offsets_are_rewritten_for_a_reduced_decode() { + use crate::processing::options::{Gravity, GravityType}; + + let original = (2000, 1600); + let shrunk = (500, 400); + + let with_gravity = |gravity: Gravity| { + let mut options = ParsedOptions { + crop: Some(Crop { + width: 1000.0, + height: 800.0, + gravity: Some(gravity), + }), + ..ParsedOptions::default() + }; + rescale_crop(&mut options, original, shrunk); + options.crop.unwrap().gravity.unwrap() + }; + + // Absolute offsets are pixel counts and scale per axis. + let scaled = with_gravity(Gravity { + kind: GravityType::NorthWest, + x: 400.0, + y: 200.0, + }); + assert_eq!((scaled.x, scaled.y), (100.0, 50.0)); + + // Anything below 1 is a fraction of the axis and already scales itself. + let fractional = with_gravity(Gravity { + kind: GravityType::NorthWest, + x: 0.25, + y: 0.5, + }); + assert_eq!((fractional.x, fractional.y), (0.25, 0.5)); + + // A focus point reads both arguments as 0..1 coordinates throughout. + let focus = with_gravity(Gravity { + kind: GravityType::FocusPoint, + x: 0.5, + y: 0.25, + }); + assert_eq!((focus.x, focus.y), (0.5, 0.25)); +} + +/// The crop falls back to the request's `gravity` when it names none, but that +/// same field positions the *resized* image for a fill — which is not measured +/// in source pixels. Scaling it in place would fix the crop by breaking the fill, +/// so the scaled copy is written into the crop's own gravity. +#[test] +fn rescaling_a_crop_leaves_the_requests_own_gravity_alone() { + use crate::processing::options::{Gravity, GravityType}; + + let request_gravity = Gravity { + kind: GravityType::NorthWest, + x: 400.0, + y: 200.0, + }; + let mut options = ParsedOptions { + crop: Some(Crop { + width: 1000.0, + height: 800.0, + gravity: None, + }), + gravity: Some(request_gravity), + ..ParsedOptions::default() + }; + + rescale_crop(&mut options, (2000, 1600), (500, 400)); + + let crop_gravity = options.crop_gravity(); + assert_eq!((crop_gravity.x, crop_gravity.y), (100.0, 50.0)); + + let fill_gravity = options.fill_gravity(); + assert_eq!( + (fill_gravity.x, fill_gravity.y), + (400.0, 200.0), + "the fill window positions the resized image and must keep its own offsets" + ); +} + +/// An alias picks the right encoder, so it must also pick the right MIME type. +/// `format:tif` selected TIFF and then fell through `format_to_content_type`'s +/// catch-all, labelling TIFF bytes as JPEG. +#[test] +fn format_aliases_resolve_to_one_canonical_name() { + use crate::processing::save::canonical_format_name; + use crate::utils::format_to_content_type; + + for (alias, canonical, mime) in [ + ("tif", "tiff", "image/tiff"), + ("tiff", "tiff", "image/tiff"), + ("jpg", "jpeg", "image/jpeg"), + ("heic", "heif", "image/heif"), + ] { + assert_eq!(canonical_format_name(alias), Some(canonical), "{alias}"); + assert_eq!( + format_to_content_type(canonical_format_name(alias).unwrap()), + mime, + "{alias} must be described by its own media type" + ); + } + + assert_eq!(canonical_format_name("not-a-format"), None); +} + +/// The ceilings describing the *source* have to reach the key too. Every one is +/// checked after the cache lookup, and `raw` and `skip_processing` return source +/// bytes straight from the cache without reaching the checks at all — so a +/// tightened policy was simply outrun by the entry already stored. +#[test] +fn cache_keys_are_namespaced_by_the_effective_source_limits() { + let path = "/unsafe/raw:1/example"; + let jpeg_only = vec!["image/jpeg".to_string()]; + let jpeg_and_png = vec!["image/jpeg".to_string(), "image/png".to_string()]; + + let unlimited = processed_cache_key(key_parts(path)); + + let by_resolution = processed_cache_key(CacheKeyParts { + max_src_resolution: Some("10".parse().unwrap()), + ..key_parts(path) + }); + let tighter_resolution = processed_cache_key(CacheKeyParts { + max_src_resolution: Some("5".parse().unwrap()), + ..key_parts(path) + }); + assert_ne!(unlimited, by_resolution); + assert_ne!(by_resolution, tighter_resolution); + + let by_size = processed_cache_key(CacheKeyParts { + max_src_file_size: Some("1048576".parse().unwrap()), + ..key_parts(path) + }); + assert_ne!(unlimited, by_size); + assert_ne!(by_size, by_resolution); + + let by_mime = processed_cache_key(CacheKeyParts { + allowed_mime_types: Some(&jpeg_only), + ..key_parts(path) + }); + let by_wider_mime = processed_cache_key(CacheKeyParts { + allowed_mime_types: Some(&jpeg_and_png), + ..key_parts(path) + }); + assert_ne!(unlimited, by_mime); + assert_ne!(by_mime, by_wider_mime, "widening the list is a policy change"); + + // Reordering the environment variable is not a policy change, so it must + // not cold-start the cache. + let reordered = vec!["image/png".to_string(), "image/jpeg".to_string()]; + assert_eq!( + by_wider_mime, + processed_cache_key(CacheKeyParts { + allowed_mime_types: Some(&reordered), + ..key_parts(path) + }) + ); + + // A deployment that sets none of them keeps the keys it already had. + assert_eq!(unlimited, processed_cache_key(key_parts(path))); +} + +/// A `raw` response takes the bare path rather than the full processed key, so +/// the source limits have to be applied to that path too — it is the one shape +/// of response that returns origin bytes with no processing between them and the +/// client. +#[test] +fn a_raw_key_still_carries_the_source_limits() { + let path = "/unsafe/raw:1/example"; + + let plain = processed_cache_key(CacheKeyParts { + is_raw: true, + ..key_parts(path) + }); + assert_eq!(plain, path, "an unrestricted deployment keeps the bare path"); + + let restricted = processed_cache_key(CacheKeyParts { + is_raw: true, + max_src_resolution: Some("5".parse().unwrap()), + ..key_parts(path) + }); + assert_ne!(restricted, path); + assert!(restricted.ends_with(path), "the path stays the tail of the key"); + + // The result-side namespaces stay out of a raw key: nothing is processed, + // so they cannot change these bytes. + let with_result_limit = processed_cache_key(CacheKeyParts { + is_raw: true, + max_result_dimension: Some("100".parse().unwrap()), + ..key_parts(path) + }); + assert_eq!(with_result_limit, path); +} + +/// A fixed `IMGFORGE_DEFAULT_FORMAT` is a standing instruction that every +/// response is that format, so a URL naming none is still asking for a +/// conversion. Reading an absent URL format as "same as the source" let +/// `skip_processing` hand back the original from a deployment configured to +/// serve only WebP. +#[test] +fn skip_processing_respects_a_fixed_default_format() { + let listed = |formats: &[&str]| ParsedOptions { + skip_processing: formats.iter().map(|f| f.to_string()).collect(), + ..ParsedOptions::default() + }; + + // With the source's own format as the default, nothing is being converted. + assert!(can_skip_processing( + &listed(&["jpeg"]), + Some("jpeg"), + DefaultOutputFormat::Source + )); + + // A fixed default that differs is a conversion, so the pipeline has to run. + assert!(!can_skip_processing( + &listed(&["jpeg"]), + Some("jpeg"), + "webp".parse().expect("webp is a valid default format") + )); + + // A fixed default that matches the source is not. + assert!(can_skip_processing( + &listed(&["jpeg"]), + Some("jpeg"), + "jpeg".parse().expect("jpeg is a valid default format") + )); + + // An explicit URL format still wins over the configured default. + let mut explicit = listed(&["jpeg"]); + explicit.format = Some("jpeg".to_string()); + assert!(can_skip_processing( + &explicit, + Some("jpeg"), + "webp".parse().expect("webp is a valid default format") + )); +} + +/// `watermark:1` names no image of its own — the overlay comes from +/// `IMGFORGE_WATERMARK_PATH`. Repointing that setting changes the bytes of every +/// watermarked response while leaving every URL identical, so the old logo was +/// served until the entries aged out. +#[test] +fn cache_keys_follow_the_configured_watermark() { + let path = "/unsafe/rs:fit:100:100/wm:0.5/example"; + + let unwatermarked = processed_cache_key(key_parts(path)); + let logo = processed_cache_key(CacheKeyParts { + watermark_path: Some("/etc/imgforge/logo.png"), + ..key_parts(path) + }); + let new_logo = processed_cache_key(CacheKeyParts { + watermark_path: Some("/etc/imgforge/logo-2026.png"), + ..key_parts(path) + }); + + assert_ne!(unwatermarked, logo); + assert_ne!(logo, new_logo, "repointing the watermark must retire the old entries"); + + // A deployment that configures none keeps the keys it had. + assert_eq!(unwatermarked, processed_cache_key(key_parts(path))); +} + +/// The configured defaults seed the parse, so `IMGFORGE_QUALITY` and its +/// neighbours change the bytes exactly as a URL option would. Unlike a release, +/// a config change carries no version bump to retire what it invalidates. +#[test] +fn cache_keys_follow_the_configured_option_defaults() { + let path = "/unsafe/rs:fit:100:100/example"; + let unconfigured = processed_cache_key(key_parts(path)); + + let with_quality = |quality: u8| { + processed_cache_key(CacheKeyParts { + option_defaults: Some(OptionDefaults { + quality: Some(quality), + ..OptionDefaults::default() + }), + ..key_parts(path) + }) + }; + assert_ne!(unconfigured, with_quality(85)); + assert_ne!( + with_quality(85), + with_quality(20), + "lowering the default must retire the old bytes" + ); + + // A flag is as byte-affecting as a number. + let stripped = processed_cache_key(CacheKeyParts { + option_defaults: Some(OptionDefaults { + strip_metadata: true, + ..OptionDefaults::default() + }), + ..key_parts(path) + }); + assert_ne!(unconfigured, stripped); + assert_ne!( + stripped, + with_quality(85), + "different settings must not share a namespace" + ); + + // A deployment that changes none of them keeps the keys it had. + assert_eq!(unconfigured, processed_cache_key(key_parts(path))); +} + +/// The alias table lives in exactly one place. This used to be a second copy +/// that spelled out jpg/jpeg and heic/heif and simply omitted tif/tiff, so +/// `skip_processing:tif` never matched a TIFF source — the same drift that made +/// `format_quality:tif:20` miss its lookup, one file over. +#[test] +fn skip_processing_understands_every_format_alias() { + let listed = |format: &str| ParsedOptions { + skip_processing: vec![format.to_string()], + ..ParsedOptions::default() + }; + + for (alias, source) in [ + ("tif", "tiff"), + ("tiff", "tiff"), + ("jpg", "jpeg"), + ("jpeg", "jpeg"), + ("heic", "heif"), + ("heif", "heif"), + ("png", "png"), + ] { + assert!( + can_skip_processing(&listed(alias), Some(source), DefaultOutputFormat::Source), + "skip_processing:{alias} should match a {source} source" + ); + } + + // A different format still does not match. + assert!(!can_skip_processing( + &listed("tif"), + Some("png"), + DefaultOutputFormat::Source + )); + // And a name that is no format at all only ever matches itself. + assert!(!can_skip_processing( + &listed("notaformat"), + Some("png"), + DefaultOutputFormat::Source + )); +} diff --git a/tests/handlers_integration_tests_extended.rs b/tests/handlers_integration_tests_extended.rs index 10e33ea..f7b89be 100644 --- a/tests/handlers_integration_tests_extended.rs +++ b/tests/handlers_integration_tests_extended.rs @@ -598,3 +598,213 @@ async fn test_trim_removes_the_border_through_the_handler() { "the white border should be gone, leaving just the red block" ); } + +/// A format alias picks the right encoder, so it has to pick the right media +/// type too. `format:tif` selected the TIFF encoder and then fell through +/// `format_to_content_type`'s catch-all, so clients received TIFF bytes +/// labelled `image/jpeg`. +#[tokio::test] +async fn format_aliases_are_described_by_their_own_media_type() { + let mock_server = MockServer::start().await; + let test_image = create_test_image(64, 64, [90, 140, 200, 255]); + + Mock::given(method("GET")) + .and(path("/alias.jpg")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(test_image.clone()) + .insert_header("Content-Type", "image/jpeg"), + ) + .expect(1..) + .mount(&mock_server) + .await; + + let source_url = format!("{}/alias.jpg", mock_server.uri()); + let encoded_url = URL_SAFE_NO_PAD.encode(source_url.as_bytes()); + + for (requested, expected) in [("tif", "image/tiff"), ("tiff", "image/tiff"), ("jpg", "image/jpeg")] { + let state = create_test_state_with_cache(create_test_config(vec![], vec![], true), ImgforgeCache::None).await; + let app = axum::Router::new() + .route("/{*path}", axum::routing::get(image_forge_handler)) + .with_state(state); + + let request = Request::builder() + .uri(format!("/unsafe/format:{requested}/{encoded_url}")) + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK, "format:{requested} should succeed"); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + assert_eq!( + content_type, expected, + "format:{requested} produced {expected} bytes but announced {content_type}" + ); + } +} + +/// A `raw` response returns origin bytes with nothing between them and the +/// client, and every source limit is checked after the cache lookup. Without the +/// limits in the cache identity, an entry stored under a loose policy kept being +/// served once the policy was tightened — the request was answered before the +/// check it should have failed. +#[tokio::test] +async fn a_cached_passthrough_does_not_outlive_the_source_limits() { + let mock_server = MockServer::start().await; + let test_image = create_test_image(400, 400, [10, 20, 30, 255]); + + Mock::given(method("GET")) + .and(path("/passthrough.png")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(test_image) + .insert_header("Content-Type", "image/png"), + ) + .mount(&mock_server) + .await; + + let source_url = format!("{}/passthrough.png", mock_server.uri()); + let encoded = URL_SAFE_NO_PAD.encode(source_url.as_bytes()); + let uri = format!("/unsafe/raw:1/{encoded}"); + + let cache = ImgforgeCache::new(Some(CacheConfig::Memory { capacity: 1024 * 1024 })) + .await + .unwrap(); + + // Warm the cache while nothing restricts the source. + let permissive = create_test_config(vec![], vec![], true); + let state = create_test_state_with_cache(permissive, cache.clone()).await; + let app = axum::Router::new() + .route("/{*path}", axum::routing::get(image_forge_handler)) + .with_state(state); + let (warm, _) = make_request(app, &uri).await; + assert_eq!(warm, StatusCode::OK, "the passthrough should succeed while permitted"); + + // Now forbid the source's resolution, over the same cache. 400x400 is + // 160,000 pixels, well over a 0.05 MP ceiling. + let mut restrictive = create_test_config(vec![], vec![], true); + restrictive.max_src_resolution = Some("0.05".parse().unwrap()); + let state = create_test_state_with_cache(restrictive, cache.clone()).await; + let app = axum::Router::new() + .route("/{*path}", axum::routing::get(image_forge_handler)) + .with_state(state); + let (status, _) = make_request(app, &uri).await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "a cached passthrough must not survive the limit that now forbids it" + ); + + // The same for a MIME restriction that the source does not satisfy. + let mut mime_restricted = create_test_config(vec![], vec![], true); + mime_restricted.allowed_mime_types = Some(vec!["image/jpeg".to_string()]); + let state = create_test_state_with_cache(mime_restricted, cache).await; + let app = axum::Router::new() + .route("/{*path}", axum::routing::get(image_forge_handler)) + .with_state(state); + let (status, _) = make_request(app, &uri).await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "a cached passthrough must not survive a MIME policy that now forbids it" + ); +} + +/// Both of these settings change the bytes of a response whose URL never +/// changes, and neither carries a version bump to retire what it invalidates. +/// The cache key has to carry them, and the request path has to actually pass +/// them — a key that accepts the input is no use if nothing supplies it. +#[tokio::test] +async fn cached_bytes_do_not_outlive_the_config_that_produced_them() { + let server = MockServer::start().await; + let source = create_test_image(80, 80, [200, 120, 40, 255]); + + Mock::given(method("GET")) + .and(path("/subject.png")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(source) + .insert_header("Content-Type", "image/png"), + ) + .mount(&server) + .await; + + let encoded = URL_SAFE_NO_PAD.encode(format!("{}/subject.png", server.uri()).as_bytes()); + + let respond = |config: Config, cache: ImgforgeCache, uri: String| async move { + let state = create_test_state_with_cache(config, cache).await; + let app = axum::Router::new() + .route("/{*path}", axum::routing::get(image_forge_handler)) + .with_state(state); + make_request(app, &uri).await.1 + }; + + // A configured default quality: lowering it must change what clients get, + // not be masked by an entry stored under the old one. + { + let uri = format!("/unsafe/rs:fit:60:60/format:jpeg/{encoded}"); + let cache = ImgforgeCache::new(Some(CacheConfig::Memory { capacity: 1024 * 1024 })) + .await + .unwrap(); + + let quality_config = |quality: u8| { + let mut config = create_test_config(vec![], vec![], true); + config.option_defaults.quality = Some(quality); + config + }; + + // Compared by size rather than by bytes: a lower quality is a smaller + // JPEG, and a failure then prints two numbers instead of two images. + let high = respond(quality_config(95), cache.clone(), uri.clone()).await.len(); + let low = respond(quality_config(20), cache, uri).await.len(); + assert!( + low < high, + "lowering IMGFORGE_QUALITY must not be outrun by the entry stored under the old value \ + (q20 gave {low} bytes, q95 gave {high})" + ); + } + + // A server-side watermark: repointing it must change every watermarked + // response, and `watermark:1` names no image of its own. + { + let dir = tempfile::tempdir().expect("a temp dir"); + let red = dir.path().join("red.png"); + let blue = dir.path().join("blue.png"); + std::fs::write(&red, create_test_image(20, 20, [255, 0, 0, 255])).unwrap(); + std::fs::write(&blue, create_test_image(20, 20, [0, 0, 255, 255])).unwrap(); + + let uri = format!("/unsafe/rs:fit:60:60/wm:1/format:png/{encoded}"); + let cache = ImgforgeCache::new(Some(CacheConfig::Memory { capacity: 1024 * 1024 })) + .await + .unwrap(); + + let watermark_config = |path: &std::path::Path| { + let mut config = create_test_config(vec![], vec![], true); + config.watermark_path = Some(path.to_string_lossy().into_owned()); + config + }; + + // Compared as mean channel values: a red overlay and a blue one differ + // in a way two byte vectors cannot report readably. + let channel_means = |body: &[u8]| { + let image = image::load_from_memory(body).expect("a decodable image").to_rgb8(); + let count = image.pixels().len() as f64; + ( + image.pixels().map(|p| f64::from(p[0])).sum::() / count, + image.pixels().map(|p| f64::from(p[2])).sum::() / count, + ) + }; + let (red_r, red_b) = channel_means(&respond(watermark_config(&red), cache.clone(), uri.clone()).await); + let (blue_r, blue_b) = channel_means(&respond(watermark_config(&blue), cache, uri).await); + assert!( + blue_b > red_b && red_r > blue_r, + "repointing IMGFORGE_WATERMARK_PATH must retire the entries composited with the old logo \ + (red logo gave r={red_r:.1} b={red_b:.1}, blue logo gave r={blue_r:.1} b={blue_b:.1})" + ); + } +} From b9b0d92d6757d6689587767352ac8259725d1759 Mon Sep 17 00:00:00 2001 From: Rafi Date: Fri, 21 Aug 2026 19:03:00 -0400 Subject: [PATCH 2/4] Use the embedded thumbnail only when it can cover the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An undersized EXIF thumbnail was substituted unconditionally, so a 1000px request with enlarge:false came back at the thumbnail's own 160px, and a request with no resize at all got the thumbnail instead of the source. The stand-in is now taken only when it is at least as large as everything the request asks of it — resize, minimums, dpr and zoom folded in the same way the scale-on-load plan folds them — and never under a crop, trim, raw, or a force resize with a zero axis. The substitution also replaced the bytes downstream metadata reads came from, so keep_copyright read the metadata-free thumbnail and silently dropped the source's copyright, and auto-rotation lost the parent's EXIF orientation. Opening the source now hands back the decode bytes and the metadata bytes separately: sniffing and reduced-scale reopening see the bytes the pixels came from, while orientation and copyright always read the original source. Co-Authored-By: Claude Fable 5 --- src/processing/mod.rs | 2 +- src/processing/scale_on_load.rs | 40 +++++++++++++++ src/processing/tests/pipeline_tests.rs | 48 ++++++++++++++++++ src/service/mod.rs | 67 ++++++++++++++++++++------ src/service/source.rs | 7 ++- 5 files changed, 146 insertions(+), 18 deletions(-) diff --git a/src/processing/mod.rs b/src/processing/mod.rs index 7e69555..b468065 100644 --- a/src/processing/mod.rs +++ b/src/processing/mod.rs @@ -22,7 +22,7 @@ use std::time::Instant; use thiserror::Error; use tracing::debug; -pub use scale_on_load::{load_scale_factor, load_shrink_factor}; +pub use scale_on_load::{load_scale_factor, load_shrink_factor, thumbnail_covers}; /// Errors produced by the image processing pipeline. #[derive(Debug, Error)] diff --git a/src/processing/scale_on_load.rs b/src/processing/scale_on_load.rs index 8c168c1..43d748a 100644 --- a/src/processing/scale_on_load.rs +++ b/src/processing/scale_on_load.rs @@ -92,6 +92,46 @@ fn load_shrink_ratio(parsed_options: &ParsedOptions, src_width: u32, src_height: (ratio.is_finite() && ratio >= MIN_LOAD_SHRINK).then_some(ratio) } +/// Whether an embedded thumbnail of this size can stand in for the source. +/// +/// The substitution is only a saving when it is invisible in the result, so the +/// thumbnail has to be at least as large as everything the request will ask of +/// it. A request that names no target size shows the source at the source's own +/// size, which a thumbnail by definition is not; a crop addresses source pixels +/// by coordinate, and the thumbnail is a different image in those coordinates; +/// what trim needs is decided by the pixels and cannot be known here. +pub fn thumbnail_covers(parsed_options: &ParsedOptions, thumb_width: i32, thumb_height: i32) -> bool { + if parsed_options.raw || parsed_options.trim.is_some() || parsed_options.crop.is_some() { + return false; + } + let Some(resize) = parsed_options.resize.as_ref() else { + return false; + }; + + // `force` fills a zero axis from the source dimension — the full-size + // source, which the thumbnail cannot supply. + if resize.resizing_type.fills_zero_axis_from_source() && (resize.width == 0 || resize.height == 0) { + return false; + } + + // Folded in exactly as the scale-on-load plan folds them: anything that can + // grow the target grows what the thumbnail must cover. + let grow = f64::from(parsed_options.dpr_factor()) * f64::from(parsed_options.zoom_factors().max_factor()); + let target_width = (f64::from(resize.width) * grow).max(f64::from(parsed_options.min_width.unwrap_or(0))); + let target_height = (f64::from(resize.height) * grow).max(f64::from(parsed_options.min_height.unwrap_or(0))); + + // Neither axis names a size: the request is not asking for a smaller image, + // it is asking for the image. + if target_width < 1.0 && target_height < 1.0 { + return false; + } + + // A zero axis is derived from the aspect ratio, which the thumbnail keeps, + // so only the named axes constrain it. + (target_width < 1.0 || f64::from(thumb_width) >= target_width) + && (target_height < 1.0 || f64::from(thumb_height) >= target_height) +} + /// Power-of-two shrink for the JPEG loader, or 1 to decode at full size. pub fn load_shrink_factor(parsed_options: &ParsedOptions, src_width: u32, src_height: u32) -> u32 { let Some(ratio) = load_shrink_ratio(parsed_options, src_width, src_height) else { diff --git a/src/processing/tests/pipeline_tests.rs b/src/processing/tests/pipeline_tests.rs index b6201be..e2c10db 100644 --- a/src/processing/tests/pipeline_tests.rs +++ b/src/processing/tests/pipeline_tests.rs @@ -334,6 +334,54 @@ fn test_load_shrink_declines_when_it_cannot_reason_about_the_target() { ); } +/// An embedded thumbnail may only stand in for the source when the result +/// cannot tell the difference — a 160x120 thumbnail answering a 1000px request +/// is the difference. +#[test] +fn test_thumbnail_stands_in_only_when_it_covers_the_request() { + use crate::processing::thumbnail_covers; + + let with = |f: fn(&mut ParsedOptions)| { + let mut o = ParsedOptions { + resize: Some(Resize { + resizing_type: ResizingType::Fit, + width: 100, + height: 100, + }), + ..ParsedOptions::default() + }; + f(&mut o); + thumbnail_covers(&o, 160, 120) + }; + + assert!(with(|_| ()), "a 160x120 thumbnail covers a 100x100 fit"); + // The two failure modes from the report: a target beyond the thumbnail, + // and no target at all, which means the source at its own size. + assert!(!with(|o| o.resize.as_mut().unwrap().width = 1000)); + assert!(!with(|o| o.resize = None)); + // Growth after the resize counts against the thumbnail too. + assert!(!with(|o| o.dpr = Some(2.0)), "dpr 2 needs 200px from 160"); + assert!(!with(|o| o.zoom = Some(Zoom { x: 2.0, y: 2.0 }))); + assert!(!with(|o| o.min_width = Some(500))); + // A zero axis is derived from the aspect ratio, which the thumbnail keeps. + assert!(with(|o| o.resize.as_mut().unwrap().height = 0)); + // `force` fills a zero axis from the source dimension, which only the + // source has. + assert!(!with(|o| { + let resize = o.resize.as_mut().unwrap(); + resize.resizing_type = ResizingType::Force; + resize.height = 0; + })); + // A crop addresses source pixels by coordinate; the thumbnail is a + // different image in those coordinates. Trim and raw need the source. + assert!(!with(|o| o.crop = Some(Crop { + width: 50.0, + height: 50.0, + gravity: None, + }))); + assert!(!with(|o| o.raw = true)); +} + /// End to end: the output must be identical whether or not the source was /// decoded at a reduced scale. #[test] diff --git a/src/service/mod.rs b/src/service/mod.rs index 75af70d..d48fed1 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -248,12 +248,16 @@ pub async fn process_path(state: Arc, request: ProcessRequest<'_>) -> .unwrap_or_else(|| "jpeg".to_string()); parsed_options.format = Some(output_format.clone()); - let (source_image, image_bytes) = open_source(&image_bytes, &parsed_options, &output_format)?; + let OpenedSource { + image: source_image, + decode_bytes, + metadata_bytes, + } = open_source(&image_bytes, &parsed_options, &output_format)?; enforce_security_constraints( blocking_state.as_ref(), &parsed_options, - &image_bytes, + &decode_bytes, source_content_type.as_deref(), Some(&source_image), )?; @@ -266,10 +270,16 @@ pub async fn process_path(state: Arc, request: ProcessRequest<'_>) -> // everything so far has read the header and nothing more. Reopening // with a shrink means the full-resolution pixels are never unpacked at // all. Same ordering imgproxy uses: load, check, then scale on load. - let load_options = loader_options(&parsed_options, sniff_image_format(&image_bytes), &output_format); - let source_image = shrink_source_on_load(source_image, &image_bytes, &mut parsed_options, &load_options); - - let processed_image_bytes = process_image(source_image, parsed_options, &image_bytes, watermark.as_ref())?; + let load_options = loader_options(&parsed_options, sniff_image_format(&decode_bytes), &output_format); + let source_image = shrink_source_on_load( + source_image, + &decode_bytes, + &metadata_bytes, + &mut parsed_options, + &load_options, + ); + + let processed_image_bytes = process_image(source_image, parsed_options, &metadata_bytes, watermark.as_ref())?; Ok::<_, ServiceError>((processed_image_bytes, output_format)) }) .await @@ -306,25 +316,48 @@ pub async fn process_path(state: Arc, request: ProcessRequest<'_>) -> }) } +/// What opening the source produced: the image and the two byte views the +/// stages after it read. +struct OpenedSource { + image: VipsImage, + /// The bytes the pixels came from — the embedded thumbnail when it was + /// substituted. Format sniffing and reduced-scale reopening have to see + /// these, because they describe the image actually in hand. + decode_bytes: Bytes, + /// The bytes EXIF-driven behaviour reads — always the original source. A + /// thumbnail carries no metadata of its own, so reading it made + /// `keep_copyright` silently lose the source's copyright and auto-rotation + /// lose the orientation that still applies to the thumbnail's pixels. + metadata_bytes: Bytes, +} + /// Opens the source, honouring `enforce_thumbnail` and the multi-page plan. -/// -/// Returns the bytes that were actually opened alongside the image, because -/// choosing the embedded thumbnail replaces them — everything downstream that -/// reads EXIF or sniffs the format has to see the same bytes the pixels came -/// from. fn open_source( image_bytes: &Bytes, parsed_options: &ParsedOptions, output_format: &str, -) -> Result<(VipsImage, Bytes), ServiceError> { +) -> Result { if parsed_options.enforce_thumbnail { if let Some(thumbnail) = metadata::embedded_thumbnail(image_bytes) { let thumbnail = Bytes::from(thumbnail); match VipsImage::new_from_buffer(&thumbnail, "") { - Ok(img) => { + // The stand-in is only taken when it covers what the request + // asks of it. An undersized one used to be taken anyway, and + // `enlarge:false` then capped a 1000px request at the 160px the + // thumbnail could provide. + Ok(img) if crate::processing::thumbnail_covers(parsed_options, img.get_width(), img.get_height()) => { debug!("Using the source's embedded thumbnail ({} bytes)", thumbnail.len()); - return Ok((img, thumbnail)); + return Ok(OpenedSource { + image: img, + decode_bytes: thumbnail, + metadata_bytes: image_bytes.clone(), + }); } + Ok(img) => debug!( + "Embedded thumbnail ({}x{}) cannot satisfy the request; using the full image", + img.get_width(), + img.get_height() + ), // A thumbnail that will not decode is not a reason to fail the // request; the full image is still there. Err(err) => debug!("Embedded thumbnail did not decode ({}); using the full image", err), @@ -336,7 +369,11 @@ fn open_source( let image = VipsImage::new_from_buffer(image_bytes, &load_options) .map_err(|source| ServiceError::SourceImageDecode { source })?; - Ok((image, image_bytes.clone())) + Ok(OpenedSource { + image, + decode_bytes: image_bytes.clone(), + metadata_bytes: image_bytes.clone(), + }) } /// Retrieve metadata for an image without processing it. diff --git a/src/service/source.rs b/src/service/source.rs index 4cf7abe..a3e7c69 100644 --- a/src/service/source.rs +++ b/src/service/source.rs @@ -227,6 +227,7 @@ fn rescale_gravity(gravity: Gravity, x_scale: f64, y_scale: f64) -> Gravity { pub fn shrink_source_on_load( source_image: VipsImage, image_bytes: &Bytes, + metadata_bytes: &Bytes, parsed_options: &mut ParsedOptions, base_load_options: &str, ) -> VipsImage { @@ -242,8 +243,10 @@ pub fn shrink_source_on_load( // EXIF orientations 5-8 swap the axes, and the rotation happens after the // load. The plan is written against what the viewer sees, so the factor has - // to be chosen against the rotated dimensions, not the stored ones. - let (width, height) = if swaps_axes(parsed_options, image_bytes) { + // to be chosen against the rotated dimensions, not the stored ones — and + // read from the bytes the rotation will actually come from, which for a + // substituted thumbnail are the parent's, not the ones being decoded. + let (width, height) = if swaps_axes(parsed_options, metadata_bytes) { (height, width) } else { (width, height) From 8a7608cb740efb66333b7118934f9c46f029773c Mon Sep 17 00:00:00 2001 From: Rafi Date: Fri, 21 Aug 2026 19:03:00 -0400 Subject: [PATCH 3/4] Keep 16-bit precision through the ICC transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transform's device space depth defaults to 8 bits, so a profiled 16-bit source bound for a high-bit-depth output was quantised on its way through — the later hop restored the Rgb16 interpretation around pixels that had already lost half their bits. The depth now follows the processing space the pipeline chose. Co-Authored-By: Claude Fable 5 --- src/processing/colorspace.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/processing/colorspace.rs b/src/processing/colorspace.rs index e9b32bd..3f6e8c6 100644 --- a/src/processing/colorspace.rs +++ b/src/processing/colorspace.rs @@ -64,9 +64,19 @@ pub fn to_processing(img: VipsImage, keep_high_bit_depth: bool) -> Result 16, + _ => 8, + }; let options = ops::IccTransformOptions { embedded: true, intent: ops::Intent::Relative, + depth, ..Default::default() }; match ops::icc_transform_with_opts(&img, "srgb", &options) { From 2d561eb886bf01a63a62007edea9e9ec2d5a9b57 Mon Sep 17 00:00:00 2001 From: Rafi Date: Fri, 21 Aug 2026 19:12:33 -0400 Subject: [PATCH 4/4] Measure the ceilings on the source and the gate on the rotated shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substituting the embedded thumbnail handed the security check the stand-in, so max_src_resolution judged a 10000px source at its thumbnail's size. Opening the source now keeps the original alongside the stand-in and the ceilings are measured on it — a source that will not even open no longer gets to hide behind a thumbnail that would pass. The size gate also compared the thumbnail's stored axes, but the parent's EXIF orientation applies to the thumbnail's pixels too, and orientations 5-8 transpose them after decoding. The gate now judges the shape the viewer gets, so a portrait request is neither accepted on the landscape numbers nor refused on them. Co-Authored-By: Claude Fable 5 --- src/processing/mod.rs | 2 +- src/processing/tests.rs | 2 +- src/service/mod.rs | 114 ++++++++++++++++++++++++++++++---------- src/service/tests.rs | 110 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 31 deletions(-) diff --git a/src/processing/mod.rs b/src/processing/mod.rs index b468065..1da9400 100644 --- a/src/processing/mod.rs +++ b/src/processing/mod.rs @@ -263,4 +263,4 @@ fn enforce_result_dimension(parsed_options: &ParsedOptions, img: &VipsImage) -> } #[cfg(test)] -mod tests; +pub(crate) mod tests; diff --git a/src/processing/tests.rs b/src/processing/tests.rs index e31bd65..64799b1 100644 --- a/src/processing/tests.rs +++ b/src/processing/tests.rs @@ -1,6 +1,6 @@ #[cfg(test)] #[path = "tests_support.rs"] -mod tests_support; +pub(crate) mod tests_support; #[cfg(test)] #[path = "tests/options_parse_tests.rs"] diff --git a/src/service/mod.rs b/src/service/mod.rs index d48fed1..962c10e 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -248,20 +248,26 @@ pub async fn process_path(state: Arc, request: ProcessRequest<'_>) -> .unwrap_or_else(|| "jpeg".to_string()); parsed_options.format = Some(output_format.clone()); - let OpenedSource { - image: source_image, - decode_bytes, - metadata_bytes, - } = open_source(&image_bytes, &parsed_options, &output_format)?; + let opened = open_source(&image_bytes, &parsed_options, &output_format)?; + // Measured on the source as fetched, never on a substituted stand-in: + // the ceilings say what this deployment will accept, and a 10000px + // source is exactly as unacceptable when its thumbnail is small. enforce_security_constraints( blocking_state.as_ref(), &parsed_options, - &decode_bytes, + &opened.metadata_bytes, source_content_type.as_deref(), - Some(&source_image), + Some(opened.constraint_image()), )?; + let OpenedSource { + image: source_image, + decode_bytes, + metadata_bytes, + .. + } = opened; + // Scale-on-load. The guards above must see the *original* dimensions, // so this comes after them: shrinking first would let a source sneak // past a resolution limit by arriving smaller than it really is. @@ -320,6 +326,11 @@ pub async fn process_path(state: Arc, request: ProcessRequest<'_>) -> /// stages after it read. struct OpenedSource { image: VipsImage, + /// The source as opened for the ceiling checks, present when a stand-in + /// occupies `image`. The ceilings describe the source as fetched, so they + /// are measured on it — judging them on the stand-in let a source over + /// `max_src_resolution` slip under the limit at its thumbnail's size. + original: Option, /// The bytes the pixels came from — the embedded thumbnail when it was /// substituted. Format sniffing and reduced-scale reopening have to see /// these, because they describe the image actually in hand. @@ -331,6 +342,13 @@ struct OpenedSource { metadata_bytes: Bytes, } +impl OpenedSource { + /// The image the source ceilings are measured on. + fn constraint_image(&self) -> &VipsImage { + self.original.as_ref().unwrap_or(&self.image) + } +} + /// Opens the source, honouring `enforce_thumbnail` and the multi-page plan. fn open_source( image_bytes: &Bytes, @@ -339,28 +357,8 @@ fn open_source( ) -> Result { if parsed_options.enforce_thumbnail { if let Some(thumbnail) = metadata::embedded_thumbnail(image_bytes) { - let thumbnail = Bytes::from(thumbnail); - match VipsImage::new_from_buffer(&thumbnail, "") { - // The stand-in is only taken when it covers what the request - // asks of it. An undersized one used to be taken anyway, and - // `enlarge:false` then capped a 1000px request at the 160px the - // thumbnail could provide. - Ok(img) if crate::processing::thumbnail_covers(parsed_options, img.get_width(), img.get_height()) => { - debug!("Using the source's embedded thumbnail ({} bytes)", thumbnail.len()); - return Ok(OpenedSource { - image: img, - decode_bytes: thumbnail, - metadata_bytes: image_bytes.clone(), - }); - } - Ok(img) => debug!( - "Embedded thumbnail ({}x{}) cannot satisfy the request; using the full image", - img.get_width(), - img.get_height() - ), - // A thumbnail that will not decode is not a reason to fail the - // request; the full image is still there. - Err(err) => debug!("Embedded thumbnail did not decode ({}); using the full image", err), + if let Some(opened) = thumbnail_stand_in(image_bytes, Bytes::from(thumbnail), parsed_options) { + return Ok(opened); } } } @@ -371,11 +369,69 @@ fn open_source( Ok(OpenedSource { image, + original: None, decode_bytes: image_bytes.clone(), metadata_bytes: image_bytes.clone(), }) } +/// The embedded thumbnail as a stand-in for the source, when it qualifies. +/// +/// `None` means the full image should be opened instead — never a failed +/// request, because the full image is still there. +fn thumbnail_stand_in(image_bytes: &Bytes, thumbnail: Bytes, parsed_options: &ParsedOptions) -> Option { + let img = match VipsImage::new_from_buffer(&thumbnail, "") { + Ok(img) => img, + Err(err) => { + debug!("Embedded thumbnail did not decode ({}); using the full image", err); + return None; + } + }; + + // The gate below is written against what the viewer sees. The parent's + // EXIF orientation applies to the thumbnail's pixels too — same scene, + // same sensor — and orientations 5-8 transpose them after decoding, so a + // stored 160x120 answers a portrait request as 120x160. + let (width, height) = if source::swaps_axes(parsed_options, image_bytes) { + (img.get_height(), img.get_width()) + } else { + (img.get_width(), img.get_height()) + }; + + // The stand-in is only taken when it covers what the request asks of it. + // An undersized one used to be taken anyway, and `enlarge:false` then + // capped a 1000px request at the 160px the thumbnail could provide. + if !crate::processing::thumbnail_covers(parsed_options, width, height) { + debug!( + "Embedded thumbnail ({}x{}) cannot satisfy the request; using the full image", + width, height + ); + return None; + } + + // The source itself still has to be open for the ceiling checks. One that + // will not open cannot be measured against them, so it does not get to + // stand behind a thumbnail that would pass. + let original = match VipsImage::new_from_buffer(image_bytes, "") { + Ok(original) => original, + Err(err) => { + debug!( + "Source did not open for the ceiling checks ({}); using the full image", + err + ); + return None; + } + }; + + debug!("Using the source's embedded thumbnail ({} bytes)", thumbnail.len()); + Some(OpenedSource { + image: img, + original: Some(original), + decode_bytes: thumbnail, + metadata_bytes: image_bytes.clone(), + }) +} + /// Retrieve metadata for an image without processing it. pub async fn image_info(state: Arc, request: ProcessRequest<'_>) -> Result { let config = &state.config; diff --git a/src/service/tests.rs b/src/service/tests.rs index 6b5f357..1d72a57 100644 --- a/src/service/tests.rs +++ b/src/service/tests.rs @@ -278,6 +278,116 @@ fn axis_swapping_orientations_are_recognised() { assert!(!swaps_axes(&rotating, &Bytes::from_static(b"not an image"))); } +/// A JPEG whose EXIF block carries an orientation in IFD0 and a real embedded +/// thumbnail in IFD1 — the shape `enforce_thumbnail` looks for. Built by hand +/// like `exif_orientation_jpeg`, so the fixture needs no checked-in binary. +fn thumbnail_jpeg(orientation: u16, thumb: &[u8]) -> Vec { + use image::{ImageBuffer, ImageFormat, Rgb}; + + let mut base = Vec::new(); + ImageBuffer::, Vec>::from_pixel(80, 40, Rgb([10, 20, 30])) + .write_to(&mut std::io::Cursor::new(&mut base), ImageFormat::Jpeg) + .unwrap(); + + let mut tiff = Vec::new(); + tiff.extend_from_slice(b"II"); // little-endian + tiff.extend_from_slice(&42u16.to_le_bytes()); + tiff.extend_from_slice(&8u32.to_le_bytes()); // IFD0 starts here + + // IFD0: the orientation, then a link to IFD1. + let ifd1_offset = 8u32 + 2 + 12 + 4; + tiff.extend_from_slice(&1u16.to_le_bytes()); + tiff.extend_from_slice(&0x0112u16.to_le_bytes()); // Orientation + tiff.extend_from_slice(&3u16.to_le_bytes()); // SHORT + tiff.extend_from_slice(&1u32.to_le_bytes()); + tiff.extend_from_slice(&orientation.to_le_bytes()); + tiff.extend_from_slice(&0u16.to_le_bytes()); // value field is 4 bytes wide + tiff.extend_from_slice(&ifd1_offset.to_le_bytes()); + + // IFD1: where the thumbnail lives and how long it is. + let thumb_offset = ifd1_offset + 2 + 2 * 12 + 4; + tiff.extend_from_slice(&2u16.to_le_bytes()); + tiff.extend_from_slice(&0x0201u16.to_le_bytes()); // JPEGInterchangeFormat + tiff.extend_from_slice(&4u16.to_le_bytes()); // LONG + tiff.extend_from_slice(&1u32.to_le_bytes()); + tiff.extend_from_slice(&thumb_offset.to_le_bytes()); + tiff.extend_from_slice(&0x0202u16.to_le_bytes()); // JPEGInterchangeFormatLength + tiff.extend_from_slice(&4u16.to_le_bytes()); // LONG + tiff.extend_from_slice(&1u32.to_le_bytes()); + tiff.extend_from_slice(&(thumb.len() as u32).to_le_bytes()); + tiff.extend_from_slice(&0u32.to_le_bytes()); // no further IFD + tiff.extend_from_slice(thumb); + + let mut app1 = Vec::from(&b"Exif\0\0"[..]); + app1.extend_from_slice(&tiff); + + let mut out = Vec::new(); + out.extend_from_slice(&base[..2]); // SOI + out.extend_from_slice(&[0xFF, 0xE1]); + out.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&app1); + out.extend_from_slice(&base[2..]); + out +} + +#[test] +fn thumbnail_substitution_is_gated_and_keeps_the_source_in_view() { + use crate::processing::options::{Resize, ResizingType}; + crate::processing::tests::tests_support::init_vips(); + + // An 80x40 source carrying a 16x8 thumbnail. + let mut thumb = Vec::new(); + image::ImageBuffer::, Vec>::from_pixel(16, 8, image::Rgb([40, 50, 60])) + .write_to(&mut std::io::Cursor::new(&mut thumb), image::ImageFormat::Jpeg) + .unwrap(); + + let request = |orientation: u16, w: u32, h: u32| -> (Bytes, ParsedOptions) { + let source = Bytes::from(thumbnail_jpeg(orientation, &thumb)); + let options = ParsedOptions { + enforce_thumbnail: true, + auto_rotate: true, + resize: Some(Resize { + resizing_type: ResizingType::Fit, + width: w, + height: h, + }), + ..ParsedOptions::default() + }; + (source, options) + }; + + // Covered: the thumbnail stands in, while metadata keeps reading the + // source and the ceilings keep measuring it. + let (source, options) = request(1, 10, 5); + let opened = open_source(&source, &options, "jpeg").unwrap(); + assert_eq!((opened.image.get_width(), opened.image.get_height()), (16, 8)); + assert_ne!(opened.decode_bytes, source, "the pixels come from the thumbnail"); + assert_eq!(opened.metadata_bytes, source, "metadata still reads the source"); + let ceiling = opened.constraint_image(); + assert_eq!( + (ceiling.get_width(), ceiling.get_height()), + (80, 40), + "the ceilings measure the source, not the stand-in" + ); + + // Beyond the thumbnail: the full image is opened instead. + let (source, options) = request(1, 50, 20); + let opened = open_source(&source, &options, "jpeg").unwrap(); + assert_eq!((opened.image.get_width(), opened.image.get_height()), (80, 40)); + assert!(opened.original.is_none()); + + // Orientation 6 shows the 16x8 thumbnail as 8x16, so a 6x10 request fits + // only the transposed shape and a 10x6 request only the stored one. The + // gate has to judge the shape the viewer gets. + let (source, options) = request(6, 6, 10); + let opened = open_source(&source, &options, "jpeg").unwrap(); + assert!(opened.original.is_some(), "covered once the axes are swapped"); + + let (source, options) = request(6, 10, 6); + let opened = open_source(&source, &options, "jpeg").unwrap(); + assert!(opened.original.is_none(), "the stored axes no longer pass the gate"); +} + #[test] fn raw_cache_keys_ignore_the_result_limit() { // The raw path inserts under the bare path, so the lookup key has to match