Image transformation
Some checks failed
CI / Typos (push) Successful in 20s
CI / Build and test (push) Failing after 2m41s
CI / Clippy (push) Successful in 3m23s
CI / Build and test (all features) (push) Failing after 10m11s
Docker / build-and-push (push) Failing after 1m1s

This commit is contained in:
2026-03-26 11:52:00 -07:00
parent 599c38ac26
commit ec7326a55e
13 changed files with 543 additions and 20 deletions

View File

@@ -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",
]

View File

@@ -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::<ObjectPath>() {
Ok(p) => result.push(p),
Err(e) => {

View File

@@ -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"]

View File

@@ -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<Arc<Vec<u8>>>),
Blob(Arc<Vec<u8>>, 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<Vec<u8>>, 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<Arc<Vec<u8>>, 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<T: ImageTransformer + Send + 'static>(
&self,
args: &str,
) -> Result<Option<PileValue>, 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<Option<PileValue>, 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::<MaxDimTransformer>(args).await,
"crop" => self.apply::<CropTransformer>(args).await,
_ => Ok(None),
}
}
#[expect(clippy::unwrap_used)]
async fn fields(&self) -> Result<Vec<Label>, std::io::Error> {
Ok(vec![
Label::new("maxdim").unwrap(),
Label::new("crop").unwrap(),
])
}
}

View File

@@ -0,0 +1,4 @@
mod pixeldim;
pub mod transformers;
pub use transformers::{CropTransformer, ImageTransformer, MaxDimTransformer};

View File

@@ -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<Self, Self::Err> {
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<D>(deserializer: D) -> Result<Self, D::Error>
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"),
}
}
}

View File

@@ -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<Self, String> {
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::<PixelDim>()?;
let h = args[1].trim().parse::<PixelDim>()?;
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);
}
}
}

View File

@@ -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<Self, String> {
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::<PixelDim>()?;
let h = args[1].parse::<PixelDim>()?;
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);
}
}
}

View File

@@ -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<Self, String>;
}

View File

@@ -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<ImageExtractor>,
}
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<Option<PileValue>, 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<Vec<Label>, 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)
}
}

View File

@@ -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)),