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
parent
e158a095d3
commit
da3d3e4c8e
|
@ -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"
|
||||
|
|
|
@ -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"] }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
}
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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<Database>,
|
||||
}
|
||||
|
||||
impl ReceiptsRepository {
|
||||
pub fn new(conn: Connection<Database>) -> Self {
|
||||
Self { conn }
|
||||
|
|
|
@ -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("/<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/<_>")]
|
||||
pub async fn view_receipt_photo(
|
||||
id: i32,
|
||||
|
@ -273,6 +299,7 @@ pub fn routes() -> Vec<Route> {
|
|||
get_receipt,
|
||||
list_receipts,
|
||||
receipt_form,
|
||||
view_receipt_thumbnail,
|
||||
view_receipt_photo,
|
||||
]
|
||||
}
|
||||
|
|
|
@ -54,7 +54,12 @@
|
|||
<img id="upload-preview" style="max-height: 400px" />
|
||||
</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-icon slot="prefix" name="image" label="Choose File"></sl-icon>
|
||||
Choose File
|
||||
|
|
|
@ -94,7 +94,7 @@
|
|||
<sl-card class="receipt-card" data-receipt-id="{{ receipt.id }}">
|
||||
<a slot="image" href="/receipts/{{ receipt.id }}">
|
||||
<img
|
||||
src="/receipts/{{ receipt.id }}/view/{{ receipt.filename }}"
|
||||
src="/receipts/{{ receipt.id }}/thumbnail/{{ receipt.filename }}"
|
||||
alt="Receipt {{ receipt.id }}"
|
||||
/>
|
||||
</a>
|
||||
|
|
Loading…
Reference in New Issue