From da3d3e4c8e16d56ff1ccadd43ae5e89af3ae7c42 Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Fri, 14 Mar 2025 20:22:14 -0500 Subject: [PATCH] receipts/list: Generate thumbnails for receipts Instead of sending the whole image file for every receipt shown on the list page, we now generate thumbnails for them on the fly. This dramatically reduces the amount of bytes sent for each image, especially very large, high-quality photographs. It also improves support for non-image attachments like PDFs, by rendering image previews in the grid view instead of a broken image placeholder. We use GraphicsMagic to do the conversion. Its `MagickWand` API is pretty straightforward and convenient, and it supports a plethora of image and image-like formats. --- Cargo.lock | 119 +++++++++++++++++++++++++++++++ Cargo.toml | 1 + Containerfile | 11 +++ src/imaging.rs | 46 ++++++++++++ src/main.rs | 3 + src/receipts.rs | 31 ++++++-- src/routes/receipts.rs | 27 +++++++ templates/receipt-form.html.tera | 7 +- templates/receipt-list.html.tera | 2 +- 9 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 src/imaging.rs diff --git a/Cargo.lock b/Cargo.lock index 8264c79..268eba6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,6 +71,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" + [[package]] name = "arrayvec" version = "0.7.6" @@ -185,6 +191,24 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.9.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.99", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -309,6 +333,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -356,6 +389,17 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -862,6 +906,28 @@ dependencies = [ "walkdir", ] +[[package]] +name = "graphicsmagick" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c4ba437067d42af001dcda9d7188f368539b0731c2f5f4a6ba3765f3b64518" +dependencies = [ + "graphicsmagick-sys", + "null-terminated-str", + "num_enum", + "thiserror 2.0.12", +] + +[[package]] +name = "graphicsmagick-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9004437800b450090fdd1045edc43647209601b68320a4be450de541d0be16f6" +dependencies = [ + "anyhow", + "bindgen", +] + [[package]] name = "h2" version = "0.4.8" @@ -1373,6 +1439,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1424,6 +1499,16 @@ version = "0.2.170" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" +[[package]] +name = "libloading" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +dependencies = [ + "cfg-if", + "windows-targets 0.52.6", +] + [[package]] name = "libm" version = "0.2.11" @@ -1648,6 +1733,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "null-terminated-str" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "594ec098b589ef9bcf24ff9d2a1ed9295851ecb4009ce1df58557c239cf250bd" + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -1711,6 +1802,27 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.99", +] + [[package]] name = "object" version = "0.36.7" @@ -2090,6 +2202,7 @@ name = "receipts" version = "0.1.0" dependencies = [ "chrono", + "graphicsmagick", "reqwest", "rocket", "rocket_db_pools", @@ -2430,6 +2543,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 2273fee..a293ce9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ keywords = ["personal-finance", "receipts"] [dependencies] chrono = { version = "0.4.40", default-features = false, features = ["serde"] } +graphicsmagick = { version = "0.6.1", features = ["v1_3_38"] } reqwest = { version = "0.12.12", features = ["json"] } rocket = { version = "0.5.1", default-features = false, features = ["json"] } rocket_db_pools = { version = "0.2.0", features = ["sqlx_macros", "sqlx_postgres"] } diff --git a/Containerfile b/Containerfile index 838340a..eb80518 100644 --- a/Containerfile +++ b/Containerfile @@ -4,7 +4,9 @@ RUN --mount=type=cache,target=/var/cache \ microdnf install -y \ --setopt persistdir=/var/cache/dnf \ --setopt install_weak_deps=0 \ + GraphicsMagick-devel \ cargo \ + clang-devel \ openssl-devel \ && : @@ -38,6 +40,15 @@ RUN --mount=type=cache,target=/root/.cargo \ FROM git.pyrocufflink.net/containerimages/dch-base +RUN --mount=type=cache,target=/var/cache \ + microdnf install -y \ + --setopt persistdir=/var/cache/dnf \ + --setopt install_weak_deps=0 \ + GraphicsMagick \ + clang-libs \ + ghostscript \ + && : + COPY --from=build /build/target/release/receipts /usr/local/bin COPY --from=esbuild /build/dist /usr/local/share/receipts/static diff --git a/src/imaging.rs b/src/imaging.rs new file mode 100644 index 0000000..b3f3457 --- /dev/null +++ b/src/imaging.rs @@ -0,0 +1,46 @@ +use graphicsmagick::types::FilterTypes; +use graphicsmagick::wand::MagickWand; + +pub fn thumbnail( + image: &[u8], +) -> Result>, graphicsmagick::Error> { + let mut wand = MagickWand::new(); + wand.read_image_blob(image)?; + + // Multi-page documents like PDFs become multiple images in the + // MagickWand. We want a thumbnail of the first page, so we have + // to reset the iterator back to the first image. + wand.reset_iterator(); + + let orig_height = wand.get_image_height() as f64; + let orig_width = wand.get_image_width() as f64; + let min_width = 300.0; + let min_height = 450.0; + let scale_w = min_width / orig_width; + let scale_h = min_height / orig_height; + let scale = scale_w.max(scale_h); + let new_width = (orig_width * scale).round() as u64; + let new_height = (orig_height * scale).round() as u64; + + wand.resize_image( + new_width, + new_height, + FilterTypes::UndefinedFilter, + 1.0, + )?; + Ok(wand + .set_image_format("WEBP")? + .write_image_blob() + .map(|a| a.to_vec())) +} + +pub fn get_type(image: &[u8]) -> Option { + Some( + MagickWand::new() + .read_image_blob(image) + .ok()? + .get_image_format() + .to_str_lossy() + .to_string(), + ) +} diff --git a/src/main.rs b/src/main.rs index ae56083..331880f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod config; mod firefly; +mod imaging; mod receipts; mod routes; mod transactions; @@ -89,6 +90,8 @@ async fn rocket() -> _ { .with_writer(std::io::stderr) .init(); + graphicsmagick::initialize(); + let rocket = rocket::build(); let figment = rocket.figment(); diff --git a/src/receipts.rs b/src/receipts.rs index 102f20c..732202b 100644 --- a/src/receipts.rs +++ b/src/receipts.rs @@ -6,6 +6,7 @@ use serde::Serialize; use sqlx::types::Decimal; use tracing::error; +use crate::imaging; use crate::Database; #[derive(Debug, Serialize)] @@ -56,6 +57,8 @@ pub enum ReceiptPostFormError { Amount(#[from] rust_decimal::Error), #[error("Error reading photo: {0}")] Photo(#[from] std::io::Error), + #[error("Unsupported image type")] + UnsupportedImageFormat, } #[derive(Serialize)] @@ -84,16 +87,31 @@ impl ReceiptPostData { use rust_decimal::prelude::FromStr; let amount = Decimal::from_str(&form.amount)?; let notes = form.notes.clone(); - let filename = form - .photo - .raw_name() - .map(|n| n.dangerous_unsafe_unsanitized_raw().as_str()) - .unwrap_or("photo.jpg") - .into(); let stream = form.photo.open().await?; let mut reader = BufReader::new(stream); let mut photo = Vec::new(); reader.read_to_end(&mut photo).await?; + let extension = match imaging::get_type(&photo).as_deref() { + Some("BMP") => "bmp", + Some("JPEG") => "jpg", + Some("PDF") => "pdf", + Some("PNG") => "png", + Some("PPM") => "ppm", + Some("TIFF") => "tiff", + Some("WEBP") => "webp", + Some(f) => { + error!("Unsupported image format: {}", f); + return Err(ReceiptPostFormError::UnsupportedImageFormat); + }, + None => { + return Err(ReceiptPostFormError::UnsupportedImageFormat); + }, + }; + let filename = form + .photo + .name() + .map(|n| format!("{}.{}", n, extension)) + .unwrap_or("photo.jpg".into()); Ok(Self { date, vendor, @@ -108,6 +126,7 @@ impl ReceiptPostData { pub struct ReceiptsRepository { conn: Connection, } + impl ReceiptsRepository { pub fn new(conn: Connection) -> Self { Self { conn } diff --git a/src/routes/receipts.rs b/src/routes/receipts.rs index 8282699..bb8ecd7 100644 --- a/src/routes/receipts.rs +++ b/src/routes/receipts.rs @@ -8,6 +8,7 @@ use rust_decimal::prelude::ToPrimitive; use tracing::{debug, error, info}; use crate::firefly::{TransactionStore, TransactionUpdate}; +use crate::imaging; use crate::receipts::*; use crate::{Context, Database}; @@ -227,6 +228,31 @@ impl PhotoResponse { } } +#[rocket::get("//thumbnail/<_>")] +pub async fn view_receipt_thumbnail( + id: i32, + db: DatabaseConnection, +) -> Option { + let mut repo = ReceiptsRepository::new(db); + match repo.get_receipt_photo(id).await { + Ok((_, image)) => { + let thumbnail = match imaging::thumbnail(&image) { + Ok(Some(t)) => t, + Ok(None) => return None, + Err(e) => { + error!("Failed to create receipt photo thumbnail: {}", e); + return None; + }, + }; + Some(PhotoResponse::new(thumbnail, ContentType(MediaType::WEBP))) + }, + Err(e) => { + error!("Error fetching receipt image: {}", e); + None + }, + } +} + #[rocket::get("//view/<_>")] pub async fn view_receipt_photo( id: i32, @@ -273,6 +299,7 @@ pub fn routes() -> Vec { get_receipt, list_receipts, receipt_form, + view_receipt_thumbnail, view_receipt_photo, ] } diff --git a/templates/receipt-form.html.tera b/templates/receipt-form.html.tera index c4b8007..98ef8f5 100644 --- a/templates/receipt-form.html.tera +++ b/templates/receipt-form.html.tera @@ -54,7 +54,12 @@

- + Choose File diff --git a/templates/receipt-list.html.tera b/templates/receipt-list.html.tera index 4b9f275..1f61e7f 100644 --- a/templates/receipt-list.html.tera +++ b/templates/receipt-list.html.tera @@ -94,7 +94,7 @@ Receipt {{ receipt.id }}