commit cf3ad87360b153360f2a20edea28ca747968f908 Author: trivernis Date: Sat Nov 6 22:31:58 2021 +0100 Initial version with image thumbnails Signed-off-by: trivernis diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/discord.xml b/.idea/discord.xml new file mode 100644 index 0000000..30bab2a --- /dev/null +++ b/.idea/discord.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..cb3dd20 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/thumbnailer.iml b/.idea/thumbnailer.iml new file mode 100644 index 0000000..457e3e6 --- /dev/null +++ b/.idea/thumbnailer.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e3a2c52 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "thumbnailer" +version = "0.1.0" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +webp = "0.2.0" +mime = "0.3.16" +thiserror = "1.0.30" +rayon = "1.5.1" + +[dependencies.image] +version = "0.23.14" \ No newline at end of file diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..1bf2a8d --- /dev/null +++ b/src/error.rs @@ -0,0 +1,20 @@ +use std::io; +use mime::Mime; +use thiserror::Error; + +pub type ThumbResult = Result; + +#[derive(Debug, Error)] +pub enum ThumbError { + #[error("IO Error {0}")] + IO(#[from] io::Error), + + #[error("Image Error {0}")] + Image(#[from] image::error::ImageError), + + #[error("Failed to decode image")] + Decode, + + #[error("Unsupported media type {0}")] + Unsupported(Mime), +} \ No newline at end of file diff --git a/src/formats/image_format.rs b/src/formats/image_format.rs new file mode 100644 index 0000000..47cb672 --- /dev/null +++ b/src/formats/image_format.rs @@ -0,0 +1,47 @@ +use std::io::{BufRead, Read, Seek}; +use image::{DynamicImage, ImageFormat}; +use mime::Mime; +use image::io::Reader as ImageReader; +use webp::Decoder as WebpDecoder; +use crate::error::{ThumbError, ThumbResult}; + +const IMAGE_WEBP_MIME: &str = "image/webp"; + +/// Reads an image with a known mime type +pub fn read_image(reader: R, mime: Mime) -> ThumbResult { + match mime.essence_str() { + IMAGE_WEBP_MIME => read_webp_image(reader), + _ => read_generic_image(reader, mime_to_image_format(mime)), + } +} + +/// Reads a webp image +fn read_webp_image(mut reader: R) -> ThumbResult { + let mut buf = Vec::new(); + reader.read_to_end(&mut buf)?; + let webp_image = WebpDecoder::new(&buf).decode().ok_or_else(|| ThumbError::Decode)?; + + Ok(webp_image.to_image()) +} + +/// Reads a generic image +fn read_generic_image(reader: R, format: Option) -> ThumbResult { + let reader = if let Some(format) = format { + ImageReader::with_format(reader, format) + } else { + ImageReader::new(reader).with_guessed_format()? + }; + let image = reader.decode()?; + + Ok(image) +} + +fn mime_to_image_format(mime: Mime) -> Option { + match mime.subtype().as_str() { + "png" => Some(ImageFormat::Png), + "jpeg" => Some(ImageFormat::Jpeg), + "bmp" => Some(ImageFormat::Bmp), + "gif" => Some(ImageFormat::Gif), + _ => None, + } +} \ No newline at end of file diff --git a/src/formats/mod.rs b/src/formats/mod.rs new file mode 100644 index 0000000..1e77a97 --- /dev/null +++ b/src/formats/mod.rs @@ -0,0 +1,15 @@ +use crate::error::{ThumbError, ThumbResult}; +use crate::formats::image_format::read_image; +use image::DynamicImage; +use mime::Mime; +use std::io::{BufRead, Seek}; + +pub mod image_format; + +/// Reads the buffer content into an image that can be used for thumbnail generation +pub fn get_base_image(reader: R, mime: Mime) -> ThumbResult { + match mime.type_() { + mime::IMAGE => read_image(reader, mime), + _ => Err(ThumbError::Unsupported(mime)), + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c6ba972 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,63 @@ +use crate::error::ThumbResult; +use image; +use image::imageops::FilterType; +use image::{DynamicImage, ImageOutputFormat}; +use mime::Mime; +use rayon::prelude::*; +use std::io::{BufRead, Seek, Write}; + +use crate::formats::get_base_image; +pub use size::ThumbnailSize; + +pub mod error; +mod formats; +mod size; + +#[derive(Clone, Debug)] +pub struct Thumbnail { + inner: DynamicImage, +} + +impl Thumbnail { + /// Writes the bytes of the image in a png format + pub fn write_png(self, writer: &mut W) -> ThumbResult<()> { + self.inner.write_to(writer, ImageOutputFormat::Png)?; + + Ok(()) + } + + /// Writes the bytes of the image in a jpeg format + pub fn write_jpeg(self, writer: &mut W, compression: u8) -> ThumbResult<()> { + self.inner + .write_to(writer, ImageOutputFormat::Jpeg(compression))?; + + Ok(()) + } +} + +/// Creates thumbnails of the requested sizes for the given reader providing the content as bytes and +/// the mime describing the contents type +pub fn create_thumbnails>( + reader: R, + mime: Mime, + sizes: I, +) -> ThumbResult> { + let image = get_base_image(reader, mime)?; + let sizes: Vec = sizes.into_iter().collect(); + let thumbnails = resize_images(image, &sizes) + .into_iter() + .map(|image| Thumbnail { inner: image }) + .collect(); + + Ok(thumbnails) +} + +fn resize_images(image: DynamicImage, sizes: &[ThumbnailSize]) -> Vec { + sizes + .into_par_iter() + .map(|size| { + let (width, height) = size.dimensions(); + image.resize(width, height, FilterType::Nearest) + }) + .collect() +} diff --git a/src/size.rs b/src/size.rs new file mode 100644 index 0000000..09cae04 --- /dev/null +++ b/src/size.rs @@ -0,0 +1,24 @@ + +/// Represents fixed sizes of a thumbnail +#[derive(Clone, Copy, Debug)] +pub enum ThumbnailSize { + Icon, + Small, + Medium, + Large, + Larger, + Custom((u32, u32)) +} + +impl ThumbnailSize { + pub fn dimensions(&self) -> (u32, u32) { + match self { + ThumbnailSize::Icon => (64, 64), + ThumbnailSize::Small => (128, 128), + ThumbnailSize::Medium => (256, 256), + ThumbnailSize::Large => (512, 512), + ThumbnailSize::Larger => (1024, 1024), + ThumbnailSize::Custom(size) => *size, + } + } +} \ No newline at end of file diff --git a/tests/assets/test.jpg b/tests/assets/test.jpg new file mode 100644 index 0000000..93771b9 Binary files /dev/null and b/tests/assets/test.jpg differ diff --git a/tests/assets/test.png b/tests/assets/test.png new file mode 100644 index 0000000..7f9aea1 Binary files /dev/null and b/tests/assets/test.png differ diff --git a/tests/assets/test.webp b/tests/assets/test.webp new file mode 100644 index 0000000..76d7cf1 Binary files /dev/null and b/tests/assets/test.webp differ diff --git a/tests/image_reading.rs b/tests/image_reading.rs new file mode 100644 index 0000000..bcd4fd4 --- /dev/null +++ b/tests/image_reading.rs @@ -0,0 +1,79 @@ +const PNG_BYTES: &'static [u8] = include_bytes!("assets/test.png"); +const JPG_BYTES: &'static [u8] = include_bytes!("assets/test.jpg"); +const WEBP_BYTES: &'static [u8] = include_bytes!("assets/test.webp"); + +use crate::ImageType::{Jpeg, Png, Webp}; +use mime::Mime; +use std::io::Cursor; +use std::str::FromStr; +use thumbnailer::error::ThumbResult; +use thumbnailer::{create_thumbnails, Thumbnail, ThumbnailSize}; + +enum ImageType { + Png, + Jpeg, + Webp, +} + +#[test] +fn it_creates_small_thumbnails_for_png() { + create_thumbnail(Png, ThumbnailSize::Small).unwrap(); +} + +#[test] +fn it_creates_medium_thumbnails_for_png() { + create_thumbnail(Png, ThumbnailSize::Medium).unwrap(); +} + +#[test] +fn it_creates_large_thumbnails_for_png() { + create_thumbnail(Png, ThumbnailSize::Large).unwrap(); +} + +#[test] +fn it_creates_small_thumbnails_for_jpeg() { + create_thumbnail(Jpeg, ThumbnailSize::Small).unwrap(); +} + +#[test] +fn it_creates_medium_thumbnails_for_jpeg() { + create_thumbnail(Jpeg, ThumbnailSize::Medium).unwrap(); +} + +#[test] +fn it_creates_large_thumbnails_for_jpeg() { + create_thumbnail(Jpeg, ThumbnailSize::Large).unwrap(); +} + +#[test] +fn it_creates_small_thumbnails_for_webp() { + create_thumbnail(Webp, ThumbnailSize::Small).unwrap(); +} + +#[test] +fn it_creates_medium_thumbnails_for_webp() { + create_thumbnail(Webp, ThumbnailSize::Medium).unwrap(); +} + +#[test] +fn it_creates_large_thumbnails_for_webp() { + create_thumbnail(Webp, ThumbnailSize::Large).unwrap(); +} + +fn create_thumbnail(image_type: ImageType, size: ThumbnailSize) -> ThumbResult> { + match image_type { + ImageType::Png => { + let reader = Cursor::new(PNG_BYTES); + create_thumbnails(reader, mime::IMAGE_PNG, [size]) + } + ImageType::Jpeg => { + let reader = Cursor::new(JPG_BYTES); + create_thumbnails(reader, mime::IMAGE_JPEG, [size]) + } + ImageType::Webp => { + let reader = Cursor::new(WEBP_BYTES); + let webp_mime = Mime::from_str("image/webp").unwrap(); + create_thumbnails(reader, webp_mime, [size]) + } + } +} diff --git a/tests/image_writing.rs b/tests/image_writing.rs new file mode 100644 index 0000000..4e8aa76 --- /dev/null +++ b/tests/image_writing.rs @@ -0,0 +1,81 @@ +use mime::Mime; +use std::io::Cursor; +use std::str::FromStr; +use thumbnailer::error::ThumbResult; +use thumbnailer::{create_thumbnails, ThumbnailSize}; + +const PNG_BYTES: &'static [u8] = include_bytes!("assets/test.png"); +const JPG_BYTES: &'static [u8] = include_bytes!("assets/test.jpg"); +const WEBP_BYTES: &'static [u8] = include_bytes!("assets/test.webp"); + +enum SourceFormat { + Png, + Jpeg, + Webp, +} + +enum TargetFormat { + Png, + Jpeg, +} + +#[test] +fn it_converts_png_thumbnails_for_png() { + write_thumbnail(SourceFormat::Png, TargetFormat::Png).unwrap(); +} + +#[test] +fn it_converts_jpeg_thumbnails_for_png() { + write_thumbnail(SourceFormat::Png, TargetFormat::Jpeg).unwrap(); +} + +#[test] +fn it_converts_png_thumbnails_for_jpeg() { + write_thumbnail(SourceFormat::Jpeg, TargetFormat::Png).unwrap(); +} + +#[test] +fn it_converts_jpeg_thumbnails_for_jpeg() { + write_thumbnail(SourceFormat::Jpeg, TargetFormat::Jpeg).unwrap(); +} + +#[test] +fn it_converts_png_thumbnails_for_webp() { + write_thumbnail(SourceFormat::Webp, TargetFormat::Png).unwrap(); +} + +#[test] +fn it_converts_jpeg_thumbnails_for_webp() { + write_thumbnail(SourceFormat::Webp, TargetFormat::Jpeg).unwrap(); +} + +fn write_thumbnail( + source_format: SourceFormat, + target_format: TargetFormat, +) -> ThumbResult> { + let thumb = match source_format { + SourceFormat::Png => { + let reader = Cursor::new(PNG_BYTES); + create_thumbnails(reader, mime::IMAGE_PNG, [ThumbnailSize::Medium]).unwrap() + } + SourceFormat::Jpeg => { + let reader = Cursor::new(JPG_BYTES); + create_thumbnails(reader, mime::IMAGE_JPEG, [ThumbnailSize::Medium]).unwrap() + } + SourceFormat::Webp => { + let reader = Cursor::new(WEBP_BYTES); + let webp_mime = Mime::from_str("image/webp").unwrap(); + create_thumbnails(reader, webp_mime, [ThumbnailSize::Medium]).unwrap() + } + } + .pop() + .unwrap(); + + let mut buf = Vec::new(); + match target_format { + TargetFormat::Png => thumb.write_png(&mut buf)?, + TargetFormat::Jpeg => thumb.write_jpeg(&mut buf, 8)?, + } + + Ok(buf) +}