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.
bugfix/ci-buildah
Dustin 2025-03-14 20:22:14 -05:00
parent e158a095d3
commit da3d3e4c8e
9 changed files with 239 additions and 8 deletions

119
Cargo.lock generated
View File

@ -71,6 +71,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anyhow"
version = "1.0.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.6" version = "0.7.6"
@ -185,6 +191,24 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -309,6 +333,15 @@ dependencies = [
"shlex", "shlex",
] ]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.0" version = "1.0.0"
@ -356,6 +389,17 @@ dependencies = [
"phf_codegen", "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]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.6" version = "0.9.6"
@ -862,6 +906,28 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "h2" name = "h2"
version = "0.4.8" version = "0.4.8"
@ -1373,6 +1439,15 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.15" version = "1.0.15"
@ -1424,6 +1499,16 @@ version = "0.2.170"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" 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]] [[package]]
name = "libm" name = "libm"
version = "0.2.11" version = "0.2.11"
@ -1648,6 +1733,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "null-terminated-str"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "594ec098b589ef9bcf24ff9d2a1ed9295851ecb4009ce1df58557c239cf250bd"
[[package]] [[package]]
name = "num-bigint-dig" name = "num-bigint-dig"
version = "0.8.4" version = "0.8.4"
@ -1711,6 +1802,27 @@ dependencies = [
"libc", "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]] [[package]]
name = "object" name = "object"
version = "0.36.7" version = "0.36.7"
@ -2090,6 +2202,7 @@ name = "receipts"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"graphicsmagick",
"reqwest", "reqwest",
"rocket", "rocket",
"rocket_db_pools", "rocket_db_pools",
@ -2430,6 +2543,12 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.0.1" version = "1.0.1"

View File

@ -11,6 +11,7 @@ keywords = ["personal-finance", "receipts"]
[dependencies] [dependencies]
chrono = { version = "0.4.40", default-features = false, features = ["serde"] } 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"] } reqwest = { version = "0.12.12", features = ["json"] }
rocket = { version = "0.5.1", default-features = false, features = ["json"] } rocket = { version = "0.5.1", default-features = false, features = ["json"] }
rocket_db_pools = { version = "0.2.0", features = ["sqlx_macros", "sqlx_postgres"] } rocket_db_pools = { version = "0.2.0", features = ["sqlx_macros", "sqlx_postgres"] }

View File

@ -4,7 +4,9 @@ RUN --mount=type=cache,target=/var/cache \
microdnf install -y \ microdnf install -y \
--setopt persistdir=/var/cache/dnf \ --setopt persistdir=/var/cache/dnf \
--setopt install_weak_deps=0 \ --setopt install_weak_deps=0 \
GraphicsMagick-devel \
cargo \ cargo \
clang-devel \
openssl-devel \ openssl-devel \
&& : && :
@ -38,6 +40,15 @@ RUN --mount=type=cache,target=/root/.cargo \
FROM git.pyrocufflink.net/containerimages/dch-base 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=build /build/target/release/receipts /usr/local/bin
COPY --from=esbuild /build/dist /usr/local/share/receipts/static COPY --from=esbuild /build/dist /usr/local/share/receipts/static

46
src/imaging.rs Normal file
View File

@ -0,0 +1,46 @@
use graphicsmagick::types::FilterTypes;
use graphicsmagick::wand::MagickWand;
pub fn thumbnail(
image: &[u8],
) -> Result<Option<Vec<u8>>, 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<String> {
Some(
MagickWand::new()
.read_image_blob(image)
.ok()?
.get_image_format()
.to_str_lossy()
.to_string(),
)
}

View File

@ -1,5 +1,6 @@
mod config; mod config;
mod firefly; mod firefly;
mod imaging;
mod receipts; mod receipts;
mod routes; mod routes;
mod transactions; mod transactions;
@ -89,6 +90,8 @@ async fn rocket() -> _ {
.with_writer(std::io::stderr) .with_writer(std::io::stderr)
.init(); .init();
graphicsmagick::initialize();
let rocket = rocket::build(); let rocket = rocket::build();
let figment = rocket.figment(); let figment = rocket.figment();

View File

@ -6,6 +6,7 @@ use serde::Serialize;
use sqlx::types::Decimal; use sqlx::types::Decimal;
use tracing::error; use tracing::error;
use crate::imaging;
use crate::Database; use crate::Database;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -56,6 +57,8 @@ pub enum ReceiptPostFormError {
Amount(#[from] rust_decimal::Error), Amount(#[from] rust_decimal::Error),
#[error("Error reading photo: {0}")] #[error("Error reading photo: {0}")]
Photo(#[from] std::io::Error), Photo(#[from] std::io::Error),
#[error("Unsupported image type")]
UnsupportedImageFormat,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -84,16 +87,31 @@ impl ReceiptPostData {
use rust_decimal::prelude::FromStr; use rust_decimal::prelude::FromStr;
let amount = Decimal::from_str(&form.amount)?; let amount = Decimal::from_str(&form.amount)?;
let notes = form.notes.clone(); 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 stream = form.photo.open().await?;
let mut reader = BufReader::new(stream); let mut reader = BufReader::new(stream);
let mut photo = Vec::new(); let mut photo = Vec::new();
reader.read_to_end(&mut photo).await?; 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 { Ok(Self {
date, date,
vendor, vendor,
@ -108,6 +126,7 @@ impl ReceiptPostData {
pub struct ReceiptsRepository { pub struct ReceiptsRepository {
conn: Connection<Database>, conn: Connection<Database>,
} }
impl ReceiptsRepository { impl ReceiptsRepository {
pub fn new(conn: Connection<Database>) -> Self { pub fn new(conn: Connection<Database>) -> Self {
Self { conn } Self { conn }

View File

@ -8,6 +8,7 @@ use rust_decimal::prelude::ToPrimitive;
use tracing::{debug, error, info}; use tracing::{debug, error, info};
use crate::firefly::{TransactionStore, TransactionUpdate}; use crate::firefly::{TransactionStore, TransactionUpdate};
use crate::imaging;
use crate::receipts::*; use crate::receipts::*;
use crate::{Context, Database}; use crate::{Context, Database};
@ -227,6 +228,31 @@ impl PhotoResponse {
} }
} }
#[rocket::get("/<id>/thumbnail/<_>")]
pub async fn view_receipt_thumbnail(
id: i32,
db: DatabaseConnection<Database>,
) -> Option<PhotoResponse> {
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("/<id>/view/<_>")] #[rocket::get("/<id>/view/<_>")]
pub async fn view_receipt_photo( pub async fn view_receipt_photo(
id: i32, id: i32,
@ -273,6 +299,7 @@ pub fn routes() -> Vec<Route> {
get_receipt, get_receipt,
list_receipts, list_receipts,
receipt_form, receipt_form,
view_receipt_thumbnail,
view_receipt_photo, view_receipt_photo,
] ]
} }

View File

@ -54,7 +54,12 @@
<img id="upload-preview" style="max-height: 400px" /> <img id="upload-preview" style="max-height: 400px" />
</p> </p>
<p> <p>
<input name="photo" type="file" style="display: none" /> <input
name="photo"
type="file"
style="display: none"
accept="image/*,application/pdf,.avif"
/>
<sl-button size="large" variant="primary" class="choose-file"> <sl-button size="large" variant="primary" class="choose-file">
<sl-icon slot="prefix" name="image" label="Choose File"></sl-icon> <sl-icon slot="prefix" name="image" label="Choose File"></sl-icon>
Choose File Choose File

View File

@ -94,7 +94,7 @@
<sl-card class="receipt-card" data-receipt-id="{{ receipt.id }}"> <sl-card class="receipt-card" data-receipt-id="{{ receipt.id }}">
<a slot="image" href="/receipts/{{ receipt.id }}"> <a slot="image" href="/receipts/{{ receipt.id }}">
<img <img
src="/receipts/{{ receipt.id }}/view/{{ receipt.filename }}" src="/receipts/{{ receipt.id }}/thumbnail/{{ receipt.filename }}"
alt="Receipt {{ receipt.id }}" alt="Receipt {{ receipt.id }}"
/> />
</a> </a>