Image transformation
This commit is contained in:
131
crates/pile-value/src/extract/item/image/mod.rs
Normal file
131
crates/pile-value/src/extract/item/image/mod.rs
Normal 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(),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
mod pixeldim;
|
||||
|
||||
pub mod transformers;
|
||||
pub use transformers::{CropTransformer, ImageTransformer, MaxDimTransformer};
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user