From 5c71e407d84e71114783a2b3f81f5d2df1695733 Mon Sep 17 00:00:00 2001 From: rm-dr <96270320+rm-dr@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:52:00 -0700 Subject: [PATCH] Image transformation --- Cargo.lock | 3 + Cargo.toml | 1 + crates/pile-dataset/Cargo.toml | 9 +- crates/pile-dataset/src/serve/extract.rs | 2 + crates/pile-value/Cargo.toml | 6 +- .../pile-value/src/extract/item/image/mod.rs | 131 ++++++++++++ .../src/extract/item/image/transform/mod.rs | 4 + .../extract/item/image/transform/pixeldim.rs | 68 +++++++ .../item/image/transform/transformers/crop.rs | 188 ++++++++++++++++++ .../image/transform/transformers/maxdim.rs | 87 ++++++++ .../item/image/transform/transformers/mod.rs | 26 +++ crates/pile-value/src/extract/item/mod.rs | 31 +-- crates/pile-value/src/value/value.rs | 7 +- 13 files changed, 543 insertions(+), 20 deletions(-) create mode 100644 crates/pile-value/src/extract/item/image/mod.rs create mode 100644 crates/pile-value/src/extract/item/image/transform/mod.rs create mode 100644 crates/pile-value/src/extract/item/image/transform/pixeldim.rs create mode 100644 crates/pile-value/src/extract/item/image/transform/transformers/crop.rs create mode 100644 crates/pile-value/src/extract/item/image/transform/transformers/maxdim.rs create mode 100644 crates/pile-value/src/extract/item/image/transform/transformers/mod.rs diff --git a/Cargo.lock b/Cargo.lock index ed1b99d..0d668fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2731,6 +2731,7 @@ version = "0.0.2" dependencies = [ "axum", "chrono", + "percent-encoding", "pile-config", "pile-io", "pile-toolbox", @@ -2802,8 +2803,10 @@ dependencies = [ "pile-flac", "pile-io", "regex", + "serde", "serde_json", "smartstring", + "strum", "tokio", "toml", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 130119c..ba2b402 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,3 +138,4 @@ regex = "1" chrono = "0.4.43" parking_lot = "0.12.5" rayon = "1.11.0" +percent-encoding = "2" diff --git a/crates/pile-dataset/Cargo.toml b/crates/pile-dataset/Cargo.toml index 6e5914c..67534b9 100644 --- a/crates/pile-dataset/Cargo.toml +++ b/crates/pile-dataset/Cargo.toml @@ -24,10 +24,17 @@ tokio-stream = { workspace = true } serde = { workspace = true, optional = true } axum = { workspace = true, optional = true } +percent-encoding = { workspace = true, optional = true } utoipa = { workspace = true, optional = true } utoipa-swagger-ui = { workspace = true, optional = true } [features] default = [] pdfium = ["pile-value/pdfium"] -axum = ["dep:axum", "dep:utoipa", "dep:utoipa-swagger-ui", "dep:serde"] +axum = [ + "dep:axum", + "dep:utoipa", + "dep:utoipa-swagger-ui", + "dep:serde", + "dep:percent-encoding", +] diff --git a/crates/pile-dataset/src/serve/extract.rs b/crates/pile-dataset/src/serve/extract.rs index a32a4e3..aa1cdd3 100644 --- a/crates/pile-dataset/src/serve/extract.rs +++ b/crates/pile-dataset/src/serve/extract.rs @@ -4,6 +4,7 @@ use axum::{ http::{StatusCode, header}, response::{IntoResponse, Response}, }; +use percent_encoding::percent_decode_str; use pile_config::{Label, objectpath::ObjectPath}; use pile_value::{extract::traits::ExtractState, value::PileValue}; use serde::Deserialize; @@ -61,6 +62,7 @@ pub async fn get_extract( if let Some((k, v)) = part.split_once('=') && k == "path" { + let v = percent_decode_str(v).decode_utf8_lossy(); match v.parse::() { Ok(p) => result.push(p), Err(e) => { diff --git a/crates/pile-value/Cargo.toml b/crates/pile-value/Cargo.toml index 2af5667..1ed558f 100644 --- a/crates/pile-value/Cargo.toml +++ b/crates/pile-value/Cargo.toml @@ -27,13 +27,15 @@ epub = { workspace = true } kamadak-exif = { workspace = true } pdf = { workspace = true } pdfium-render = { workspace = true, optional = true } -image = { workspace = true, optional = true } +image = { workspace = true } id3 = { workspace = true } tokio = { workspace = true } async-trait = { workspace = true } mime = { workspace = true } mime_guess = { workspace = true } +serde = { workspace = true } +strum = { workspace = true } [features] default = [] -pdfium = ["dep:pdfium-render", "dep:image"] +pdfium = ["dep:pdfium-render"] diff --git a/crates/pile-value/src/extract/item/image/mod.rs b/crates/pile-value/src/extract/item/image/mod.rs new file mode 100644 index 0000000..526b093 --- /dev/null +++ b/crates/pile-value/src/extract/item/image/mod.rs @@ -0,0 +1,131 @@ +mod transform; +use transform::{CropTransformer, ImageTransformer, MaxDimTransformer}; + +use image::ImageFormat; +use mime::Mime; +use pile_config::Label; +use pile_io::AsyncReader; +use std::{ + io::Cursor, + str::FromStr, + sync::{Arc, OnceLock}, +}; +use tracing::trace; + +use crate::{ + extract::traits::{ExtractState, ObjectExtractor}, + value::{Item, PileValue}, +}; + +enum ImageSource { + Item(Item, OnceLock>>), + Blob(Arc>, Mime), +} + +pub struct ImageExtractor { + source: ImageSource, +} + +impl ImageExtractor { + pub fn new(item: &Item) -> Self { + Self { + source: ImageSource::Item(item.clone(), OnceLock::new()), + } + } + + pub fn from_blob(bytes: Arc>, mime: Mime) -> Self { + Self { + source: ImageSource::Blob(bytes, mime), + } + } + + fn mime(&self) -> &Mime { + match &self.source { + ImageSource::Item(item, _) => item.mime(), + ImageSource::Blob(_, mime) => mime, + } + } + + async fn read_bytes(&self) -> Result>, std::io::Error> { + match &self.source { + ImageSource::Blob(bytes, _) => Ok(bytes.clone()), + ImageSource::Item(item, cache) => { + if let Some(x) = cache.get() { + return Ok(x.clone()); + } + let mut reader = item.read().await?; + let bytes = reader.read_to_end().await?; + Ok(cache.get_or_init(|| Arc::new(bytes)).clone()) + } + } + } + + async fn apply( + &self, + args: &str, + ) -> Result, std::io::Error> { + let transformer = match T::parse_args(args) { + Ok(t) => t, + Err(_) => return Ok(None), + }; + + let mime = self.mime().clone(); + let bytes = self.read_bytes().await?; + + let Some(format) = ImageFormat::from_mime_type(&mime) else { + return Ok(Some(PileValue::Blob { mime, bytes })); + }; + + let bytes_for_closure = bytes.clone(); + let result = tokio::task::spawn_blocking(move || { + let mut img = image::load_from_memory_with_format(&bytes_for_closure, format)?; + transformer.transform(&mut img); + + let mut out = Cursor::new(Vec::new()); + img.write_to(&mut out, format)?; + + let out_mime = + Mime::from_str(format.to_mime_type()).unwrap_or(mime::APPLICATION_OCTET_STREAM); + Ok::<_, image::ImageError>((out_mime, out.into_inner())) + }) + .await?; + + match result { + Ok((out_mime, out_bytes)) => Ok(Some(PileValue::Blob { + mime: out_mime, + bytes: Arc::new(out_bytes), + })), + Err(_) => Ok(Some(PileValue::Blob { mime, bytes })), + } + } +} + +#[async_trait::async_trait] +impl ObjectExtractor for ImageExtractor { + async fn field( + &self, + _state: &ExtractState, + name: &Label, + args: Option<&str>, + ) -> Result, std::io::Error> { + let Some(args) = args else { + return Ok(None); + }; + + trace!(?args, "Getting field {name:?} from ImageExtractor",); + + match name.as_str() { + "maxdim" => self.apply::(args).await, + "crop" => self.apply::(args).await, + _ => Ok(None), + } + } + + #[expect(clippy::unwrap_used)] + async fn fields(&self) -> Result, std::io::Error> { + Ok(vec![ + Label::new("maxdim").unwrap(), + Label::new("crop").unwrap(), + ]) + } +} diff --git a/crates/pile-value/src/extract/item/image/transform/mod.rs b/crates/pile-value/src/extract/item/image/transform/mod.rs new file mode 100644 index 0000000..ddd1da8 --- /dev/null +++ b/crates/pile-value/src/extract/item/image/transform/mod.rs @@ -0,0 +1,4 @@ +mod pixeldim; + +pub mod transformers; +pub use transformers::{CropTransformer, ImageTransformer, MaxDimTransformer}; diff --git a/crates/pile-value/src/extract/item/image/transform/pixeldim.rs b/crates/pile-value/src/extract/item/image/transform/pixeldim.rs new file mode 100644 index 0000000..b742c21 --- /dev/null +++ b/crates/pile-value/src/extract/item/image/transform/pixeldim.rs @@ -0,0 +1,68 @@ +use serde::{Deserialize, Deserializer}; +use std::fmt; +use std::str::FromStr; + +// TODO: parse -, + (100vw - 10px) +// TODO: parse 100vw [min] 10 +// TODO: parse 100vw [max] 10 + +#[derive(Debug, Clone, PartialEq)] +pub enum PixelDim { + Pixels(u32), + WidthPercent(f32), + HeightPercent(f32), +} + +impl FromStr for PixelDim { + type Err = String; + + fn from_str(s: &str) -> Result { + let numeric_end = s.find(|c: char| !c.is_ascii_digit() && c != '.'); + + let (quantity, unit) = numeric_end.map(|x| s.split_at(x)).unwrap_or((s, "px")); + let quantity = quantity.trim(); + let unit = unit.trim(); + + match unit { + "vw" => Ok(PixelDim::WidthPercent( + quantity + .parse() + .map_err(|_err| format!("invalid quantity {quantity}"))?, + )), + + "vh" => Ok(PixelDim::HeightPercent( + quantity + .parse() + .map_err(|_err| format!("invalid quantity {quantity}"))?, + )), + + "px" => Ok(PixelDim::Pixels( + quantity + .parse() + .map_err(|_err| format!("invalid quantity {quantity}"))?, + )), + + _ => Err(format!("invalid unit {unit}")), + } + } +} + +impl<'de> Deserialize<'de> for PixelDim { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + FromStr::from_str(&s).map_err(serde::de::Error::custom) + } +} + +impl fmt::Display for PixelDim { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PixelDim::Pixels(px) => write!(f, "{px}"), + PixelDim::WidthPercent(p) => write!(f, "{p:.2}vw"), + PixelDim::HeightPercent(p) => write!(f, "{p:.2}vh"), + } + } +} diff --git a/crates/pile-value/src/extract/item/image/transform/transformers/crop.rs b/crates/pile-value/src/extract/item/image/transform/transformers/crop.rs new file mode 100644 index 0000000..95070e6 --- /dev/null +++ b/crates/pile-value/src/extract/item/image/transform/transformers/crop.rs @@ -0,0 +1,188 @@ +use image::DynamicImage; +use serde::{Deserialize, Serialize}; +use std::{fmt::Display, str::FromStr}; +use strum::{Display, EnumString}; + +use super::super::{pixeldim::PixelDim, transformers::ImageTransformer}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, Serialize, Deserialize, Display)] +pub enum Direction { + #[serde(rename = "n")] + #[strum(to_string = "n")] + #[strum(serialize = "north")] + North, + + #[serde(rename = "e")] + #[strum(serialize = "e")] + #[strum(serialize = "east")] + East, + + #[serde(rename = "s")] + #[strum(serialize = "s")] + #[strum(serialize = "south")] + South, + + #[serde(rename = "w")] + #[strum(to_string = "w")] + #[strum(serialize = "west")] + West, + + #[serde(rename = "c")] + #[strum(serialize = "c")] + #[strum(serialize = "center")] + Center, + + #[serde(rename = "ne")] + #[strum(serialize = "ne")] + #[strum(serialize = "northeast")] + NorthEast, + + #[serde(rename = "se")] + #[strum(serialize = "se")] + #[strum(serialize = "southeast")] + SouthEast, + + #[serde(rename = "nw")] + #[strum(serialize = "nw")] + #[strum(serialize = "northwest")] + NorthWest, + + #[serde(rename = "sw")] + #[strum(serialize = "sw")] + #[strum(serialize = "southwest")] + SouthWest, +} + +/// Crop an image to (at most) the given size. +/// See [Self::new] for details. +#[derive(Debug, Clone, PartialEq)] +pub struct CropTransformer { + w: PixelDim, + h: PixelDim, + float: Direction, +} + +impl CropTransformer { + /// Create a new [CropTransformer] with the given parameters. + /// + /// A [CropTransformer] creates an image of size `w x h`, but... + /// - does not reduce width if `w` is greater than image width + /// - does not reduce height if `h` is greater than image height + /// - does nothing if `w` or `h` is less than or equal to zero. + #[expect(dead_code)] + pub fn new(w: PixelDim, h: PixelDim, float: Direction) -> Self { + Self { w, h, float } + } + + fn crop_dim(&self, img_width: u32, img_height: u32) -> (u32, u32) { + let crop_width = match self.w { + PixelDim::Pixels(w) => w, + PixelDim::WidthPercent(pct) => ((img_width as f32) * pct / 100.0) as u32, + PixelDim::HeightPercent(pct) => ((img_height as f32) * pct / 100.0) as u32, + }; + + let crop_height = match self.h { + PixelDim::Pixels(h) => h, + PixelDim::WidthPercent(pct) => ((img_width as f32) * pct / 100.0) as u32, + PixelDim::HeightPercent(pct) => ((img_height as f32) * pct / 100.0) as u32, + }; + + (crop_width, crop_height) + } + + #[expect(clippy::integer_division)] + fn crop_pos( + &self, + img_width: u32, + img_height: u32, + crop_width: u32, + crop_height: u32, + ) -> (u32, u32) { + match self.float { + Direction::North => { + let x = (img_width - crop_width) / 2; + let y = 0; + (x, y) + } + Direction::East => { + let x = img_width - crop_width; + let y = (img_height - crop_height) / 2; + (x, y) + } + Direction::South => { + let x = (img_width - crop_width) / 2; + let y = img_height - crop_height; + (x, y) + } + Direction::West => { + let x = 0; + let y = (img_height - crop_height) / 2; + (x, y) + } + Direction::Center => { + let x = (img_width - crop_width) / 2; + let y = (img_height - crop_height) / 2; + (x, y) + } + Direction::NorthEast => { + let x = img_width - crop_width; + let y = 0; + (x, y) + } + Direction::SouthEast => { + let x = img_width - crop_width; + let y = img_height - crop_height; + (x, y) + } + Direction::NorthWest => { + let x = 0; + let y = 0; + (x, y) + } + Direction::SouthWest => { + let x = 0; + let y = img_height - crop_height; + (x, y) + } + } + } +} + +impl Display for CropTransformer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "crop({},{},{})", self.w, self.h, self.float) + } +} + +impl ImageTransformer for CropTransformer { + fn parse_args(args: &str) -> Result { + let args: Vec<&str> = args.split(",").collect(); + if args.len() != 3 { + return Err(format!("expected 3 args, got {}", args.len())); + } + + let w = args[0].trim().parse::()?; + let h = args[1].trim().parse::()?; + + let direction = args[2].trim(); + let direction = Direction::from_str(direction) + .map_err(|_err| format!("invalid direction {direction}"))?; + + Ok(Self { + w, + h, + float: direction, + }) + } + + fn transform(&self, input: &mut DynamicImage) { + let (img_width, img_height) = (input.width(), input.height()); + let (crop_width, crop_height) = self.crop_dim(img_width, img_height); + + if (crop_width < img_width || crop_height < img_height) && crop_width > 0 && crop_height > 0 + { + let (x, y) = self.crop_pos(img_width, img_height, crop_width, crop_height); + *input = input.crop(x, y, crop_width, crop_height); + } + } +} diff --git a/crates/pile-value/src/extract/item/image/transform/transformers/maxdim.rs b/crates/pile-value/src/extract/item/image/transform/transformers/maxdim.rs new file mode 100644 index 0000000..b2c2e17 --- /dev/null +++ b/crates/pile-value/src/extract/item/image/transform/transformers/maxdim.rs @@ -0,0 +1,87 @@ +use image::{DynamicImage, imageops::FilterType}; +use std::fmt::Display; + +use super::super::{pixeldim::PixelDim, transformers::ImageTransformer}; + +/// Scale an image until it fits in a configured bounding box. +#[derive(Debug, Clone, PartialEq)] +pub struct MaxDimTransformer { + w: PixelDim, + h: PixelDim, +} + +impl MaxDimTransformer { + /// Create a new [MaxDimTransformer] that scales an image down + /// until it fits in a box of dimension `w x h`. + /// + /// Images are never scaled up. + #[expect(dead_code)] + pub fn new(w: PixelDim, h: PixelDim) -> Self { + Self { w, h } + } + + fn target_dim(&self, img_width: u32, img_height: u32) -> (u32, u32) { + let max_width = match self.w { + PixelDim::Pixels(w) => Some(w), + PixelDim::WidthPercent(pct) => Some(((img_width as f32) * pct / 100.0) as u32), + PixelDim::HeightPercent(_) => None, + }; + + let max_height = match self.h { + PixelDim::Pixels(h) => Some(h), + PixelDim::HeightPercent(pct) => Some(((img_height as f32) * pct / 100.0) as u32), + PixelDim::WidthPercent(_) => None, + }; + + if max_width.map(|x| img_width <= x).unwrap_or(true) + && max_height.map(|x| img_height <= x).unwrap_or(true) + { + return (img_width, img_height); + } + + let width_ratio = max_width + .map(|x| x as f32 / img_width as f32) + .unwrap_or(1.0); + + let height_ratio = max_height + .map(|x| x as f32 / img_height as f32) + .unwrap_or(1.0); + + let ratio = width_ratio.min(height_ratio); + + ( + (img_width as f32 * ratio) as u32, + (img_height as f32 * ratio) as u32, + ) + } +} + +impl Display for MaxDimTransformer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "maxdim({},{})", self.w, self.h) + } +} + +impl ImageTransformer for MaxDimTransformer { + fn parse_args(args: &str) -> Result { + let args: Vec<&str> = args.split(",").collect(); + if args.len() != 2 { + return Err(format!("expected 2 args, got {}", args.len())); + } + + let w = args[0].parse::()?; + let h = args[1].parse::()?; + + Ok(Self { w, h }) + } + + fn transform(&self, input: &mut DynamicImage) { + let (img_width, img_height) = (input.width(), input.height()); + let (target_width, target_height) = self.target_dim(img_width, img_height); + + // Only resize if needed + if target_width != img_width || target_height != img_height { + *input = input.resize(target_width, target_height, FilterType::Lanczos3); + } + } +} diff --git a/crates/pile-value/src/extract/item/image/transform/transformers/mod.rs b/crates/pile-value/src/extract/item/image/transform/transformers/mod.rs new file mode 100644 index 0000000..1dd3326 --- /dev/null +++ b/crates/pile-value/src/extract/item/image/transform/transformers/mod.rs @@ -0,0 +1,26 @@ +//! Defines all transformation steps we can apply to an image + +use image::DynamicImage; +use std::fmt::{Debug, Display}; + +mod crop; +pub use crop::*; + +mod maxdim; +pub use maxdim::*; + +/// A single transformation that may be applied to an image. +pub trait ImageTransformer +where + Self: PartialEq, + Self: Sized + Clone, + Self: Display + Debug, +{ + /// Transform the given image in place + fn transform(&self, input: &mut DynamicImage); + + /// Parse an arg string. + /// + /// `name({arg_string})` + fn parse_args(args: &str) -> Result; +} diff --git a/crates/pile-value/src/extract/item/mod.rs b/crates/pile-value/src/extract/item/mod.rs index 8496d25..a69b698 100644 --- a/crates/pile-value/src/extract/item/mod.rs +++ b/crates/pile-value/src/extract/item/mod.rs @@ -31,6 +31,9 @@ pub use group::*; mod text; pub use text::*; +mod image; +pub use image::*; + use crate::{ extract::{ misc::MapExtractor, @@ -41,6 +44,7 @@ use crate::{ pub struct ItemExtractor { inner: MapExtractor, + image: Arc, } impl ItemExtractor { @@ -91,7 +95,10 @@ impl ItemExtractor { ]), }; - Self { inner } + Self { + inner, + image: Arc::new(ImageExtractor::new(item)), + } } } @@ -103,22 +110,16 @@ impl ObjectExtractor for ItemExtractor { name: &pile_config::Label, args: Option<&str>, ) -> Result, std::io::Error> { - self.inner.field(state, name, args).await + if self.image.fields().await?.contains(name) { + self.image.field(state, name, args).await + } else { + self.inner.field(state, name, args).await + } } - #[expect(clippy::unwrap_used)] async fn fields(&self) -> Result, std::io::Error> { - return Ok(vec![ - Label::new("flac").unwrap(), - Label::new("id3").unwrap(), - Label::new("fs").unwrap(), - Label::new("epub").unwrap(), - Label::new("exif").unwrap(), - Label::new("pdf").unwrap(), - Label::new("json").unwrap(), - Label::new("toml").unwrap(), - Label::new("text").unwrap(), - Label::new("groups").unwrap(), - ]); + let mut fields = self.inner.fields().await?; + fields.extend(self.image.fields().await?); + Ok(fields) } } diff --git a/crates/pile-value/src/value/value.rs b/crates/pile-value/src/value/value.rs index 1e30160..3e7f0d4 100644 --- a/crates/pile-value/src/value/value.rs +++ b/crates/pile-value/src/value/value.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use crate::{ extract::{ - item::ItemExtractor, + item::{ImageExtractor, ItemExtractor}, misc::{ArrayExtractor, MapExtractor, VecExtractor}, string::StringExtractor, traits::{ExtractState, ListExtractor, ObjectExtractor}, @@ -70,7 +70,10 @@ impl PileValue { Self::I64(_) => Arc::new(MapExtractor::default()), Self::Array(_) => Arc::new(MapExtractor::default()), Self::String(s) => Arc::new(StringExtractor::new(s)), - Self::Blob { .. } => Arc::new(MapExtractor::default()), + Self::Blob { mime, bytes } => { + // TODO: make a blobextractor (with pdf, epub, etc; like item) + Arc::new(ImageExtractor::from_blob(bytes.clone(), mime.clone())) + } Self::ListExtractor(_) => Arc::new(MapExtractor::default()), Self::ObjectExtractor(e) => e.clone(), Self::Item(i) => Arc::new(ItemExtractor::new(i)),