Add Rust logo pipeline: trim, metrics, knockout and Branding settings

Logos (PNG, JPEG, WebP) are decoded, trimmed to their ink box, downscaled
to 1200 px and re-encoded as a content-addressed print PNG plus a white
knockout variant when the logo has alpha; aspect, ink density, mean colour
and kind (wordmark, mark, tall) are stored with the settings and frozen in
the issue snapshot. Legacy logos are derived at startup. The Test Vendor logo
PDF shrinks from 334 KB to 92 KB. Settings gains a Branding section with
white and dark previews and a business-name toggle (migration M5).
This commit is contained in:
2026-10-04 06:31:30 +05:30
parent 3c5fd617cd
commit e1edf02448
27 changed files with 1615 additions and 37 deletions
+270
View File
@@ -0,0 +1,270 @@
use super::assets::{relative_asset_path, resolve_asset, write_named_asset};
use super::settings::{map_settings, SETTINGS_COLS};
use crate::logo::{process_logo, sniff_extension, LogoMetrics, MAX_INPUT_BYTES};
use crate::models::Settings;
use crate::AppState;
use base64::{engine::general_purpose::STANDARD, Engine};
use rusqlite::{params, Connection};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::path::Path;
use tauri::State;
/// The stored files and measurements of an imported logo. Paths are relative to the data dir.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LogoAsset {
pub original_path: String,
pub print_path: String,
pub knockout_path: Option<String>,
pub metrics: LogoMetrics,
}
fn sha8(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().take(4).map(|b| format!("{b:02x}")).collect()
}
/// Derive the print and knockout images from `bytes` and store them content-addressed in the asset
/// store. `existing_original` is the already stored file the bytes came from (legacy logos); without
/// it the bytes are stored as the original. Nothing is ever deleted: issued invoices may still point at
/// older logo files.
fn store_logo(data_dir: &Path, bytes: &[u8], existing_original: Option<String>) -> Result<LogoAsset, String> {
let derived = process_logo(bytes).map_err(|e| e.to_string())?;
let id = sha8(bytes);
let original_path = match existing_original {
Some(path) => path,
None => {
let ext = sniff_extension(bytes).map_err(|e| e.to_string())?;
write_named_asset(data_dir, &format!("logo-{id}-original.{ext}"), bytes)?
}
};
let print_path = write_named_asset(data_dir, &format!("logo-{id}-print.png"), &derived.print_png)?;
let knockout_path = derived
.knockout_png
.as_deref()
.map(|png| write_named_asset(data_dir, &format!("logo-{id}-knockout.png"), png))
.transpose()?;
Ok(LogoAsset { original_path, print_path, knockout_path, metrics: derived.metrics })
}
pub fn import_logo_from_path(data_dir: &Path, source_path: &str) -> Result<LogoAsset, String> {
let len = std::fs::metadata(source_path).map_err(|e| e.to_string())?.len();
if len > MAX_INPUT_BYTES as u64 {
return Err(crate::logo::LogoError::TooLarge.to_string());
}
let bytes = std::fs::read(source_path).map_err(|e| e.to_string())?;
store_logo(data_dir, &bytes, None)
}
pub fn import_logo_from_base64(data_dir: &Path, data_base64: &str) -> Result<LogoAsset, String> {
// Base64 is 4/3 of the payload; refuse before decoding something huge.
if data_base64.len() > MAX_INPUT_BYTES / 3 * 4 + 8 {
return Err(crate::logo::LogoError::TooLarge.to_string());
}
let bytes = STANDARD.decode(data_base64.as_bytes()).map_err(|e| e.to_string())?;
store_logo(data_dir, &bytes, None)
}
fn metrics_json(metrics: &LogoMetrics) -> Result<String, String> {
serde_json::to_string(metrics).map_err(|e| e.to_string())
}
/// Derive trimmed print and knockout images for a logo saved before the pipeline existed
/// (`logo_path` set, `logo_meta` empty) and point the settings at them. The old file is kept as the
/// original. Returns whether anything changed.
pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result<bool, String> {
let (logo_path, meta): (Option<String>, String) = conn
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.map_err(|e| e.to_string())?;
let Some(logo_path) = logo_path.filter(|p| !p.trim().is_empty()) else {
return Ok(false);
};
if !meta.is_empty() {
return Ok(false);
}
let file = resolve_asset(data_dir, &logo_path)?;
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
let asset = store_logo(data_dir, &bytes, Some(relative_asset_path(data_dir, &logo_path)?))?;
conn.execute(
"UPDATE app_settings SET logo_path = ?1, logo_original_path = ?2, logo_knockout_path = ?3,
logo_meta = ?4 WHERE id = 1",
params![asset.print_path, asset.original_path, asset.knockout_path, metrics_json(&asset.metrics)?],
)
.map_err(|e| e.to_string())?;
Ok(true)
}
/// Detach the logo from the settings. The files stay: issued invoices may reference them.
pub fn remove_logo_impl(conn: &Connection) -> Result<(), String> {
conn.execute(
"UPDATE app_settings SET logo_path = NULL, logo_original_path = NULL, logo_knockout_path = NULL,
logo_meta = '', logo_includes_name = 'auto' WHERE id = 1",
[],
)
.map_err(|e| e.to_string())?;
Ok(())
}
fn read_settings(conn: &Connection) -> Result<Settings, String> {
conn.query_row(
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
[],
map_settings,
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn import_logo(state: State<AppState>, source_path: String) -> Result<LogoAsset, String> {
import_logo_from_path(&state.data_dir, &source_path)
}
/// For when the frontend already holds the bytes. The file name is ignored: the type comes from the bytes.
#[tauri::command]
pub fn save_logo_bytes(
state: State<AppState>,
#[allow(unused_variables)] file_name: String,
data_base64: String,
) -> Result<LogoAsset, String> {
import_logo_from_base64(&state.data_dir, &data_base64)
}
#[tauri::command]
pub fn ensure_logo_derived(state: State<AppState>) -> Result<Settings, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
ensure_logo_derived_impl(&conn, &state.data_dir)?;
read_settings(&conn)
}
#[tauri::command]
pub fn remove_logo(state: State<AppState>) -> Result<Settings, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
remove_logo_impl(&conn)?;
read_settings(&conn)
}
#[cfg(test)]
mod tests {
use super::*;
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
use std::io::Cursor;
use tempfile::tempdir;
/// A 300x100 transparent canvas with a 100x20 opaque bar off-centre.
fn padded_png() -> Vec<u8> {
let mut img = RgbaImage::new(300, 100);
for y in 40..60 {
for x in 150..250 {
img.put_pixel(x, y, Rgba([10, 20, 30, 255]));
}
}
let mut out = Vec::new();
DynamicImage::ImageRgba8(img).write_to(&mut Cursor::new(&mut out), ImageFormat::Png).unwrap();
out
}
fn logo_columns(conn: &Connection) -> (Option<String>, Option<String>, Option<String>, String) {
conn.query_row(
"SELECT logo_path, logo_original_path, logo_knockout_path, logo_meta FROM app_settings WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap()
}
#[test]
fn base64_import_stores_content_addressed_files() {
let dir = tempdir().unwrap();
let bytes = padded_png();
let asset = import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap();
let id = sha8(&bytes);
assert_eq!(asset.original_path, format!("assets/logo-{id}-original.png"));
assert_eq!(asset.print_path, format!("assets/logo-{id}-print.png"));
assert_eq!(asset.knockout_path.as_deref(), Some(format!("assets/logo-{id}-knockout.png").as_str()));
assert_eq!((asset.metrics.width, asset.metrics.height), (100, 20));
assert_eq!(std::fs::read(dir.path().join(&asset.original_path)).unwrap(), bytes);
for rel in [&asset.print_path, asset.knockout_path.as_ref().unwrap()] {
assert!(resolve_asset(dir.path(), rel).is_ok());
}
// The same bytes land on the same names.
assert_eq!(import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap().print_path, asset.print_path);
let json = serde_json::to_value(&asset).unwrap();
assert_eq!(json["metrics"]["kind"], "wordmark");
assert_eq!(json["metrics"]["opaqueBackground"], false);
}
#[test]
fn path_import_reads_the_file_and_rejects_bad_input() {
let dir = tempdir().unwrap();
let src = tempdir().unwrap();
let file = src.path().join("brand.png");
std::fs::write(&file, padded_png()).unwrap();
let asset = import_logo_from_path(dir.path(), file.to_str().unwrap()).unwrap();
assert_eq!(asset.metrics.width, 100);
let gif = src.path().join("x.gif");
std::fs::write(&gif, b"GIF89a....").unwrap();
assert_eq!(
import_logo_from_path(dir.path(), gif.to_str().unwrap()).unwrap_err(),
"Use a PNG, JPEG or WebP image"
);
assert!(import_logo_from_path(dir.path(), "/nonexistent/logo.png").is_err());
assert!(import_logo_from_base64(dir.path(), "!!not base64!!").is_err());
assert!(import_logo_from_base64(dir.path(), &STANDARD.encode(b"junk")).is_err());
}
#[test]
fn ensure_derives_a_legacy_untrimmed_logo_and_keeps_the_old_file() {
let dir = tempdir().unwrap();
let conn = crate::db::open_in_memory().unwrap();
let legacy = write_named_asset(dir.path(), "logo-legacy.png", &padded_png()).unwrap();
conn.execute("UPDATE app_settings SET logo_path = ?1", [&legacy]).unwrap();
assert!(ensure_logo_derived_impl(&conn, dir.path()).unwrap());
let (logo, original, knockout, meta) = logo_columns(&conn);
let logo = logo.unwrap();
assert!(logo.ends_with("-print.png"), "{logo}");
assert_eq!(original.as_deref(), Some(legacy.as_str()));
assert!(knockout.unwrap().ends_with("-knockout.png"));
let metrics: LogoMetrics = serde_json::from_str(&meta).unwrap();
assert_eq!((metrics.width, metrics.height), (100, 20));
assert!(dir.path().join(&legacy).is_file());
assert!(dir.path().join(&logo).is_file());
// Already derived: nothing to do the second time.
assert!(!ensure_logo_derived_impl(&conn, dir.path()).unwrap());
assert_eq!(logo_columns(&conn).0.as_deref(), Some(logo.as_str()));
}
#[test]
fn ensure_does_nothing_without_a_logo_and_errors_on_a_missing_file() {
let dir = tempdir().unwrap();
let conn = crate::db::open_in_memory().unwrap();
assert!(!ensure_logo_derived_impl(&conn, dir.path()).unwrap());
conn.execute("UPDATE app_settings SET logo_path = 'assets/gone.png'", []).unwrap();
assert!(ensure_logo_derived_impl(&conn, dir.path()).is_err());
// Left untouched so a later start can retry.
assert_eq!(logo_columns(&conn).0.as_deref(), Some("assets/gone.png"));
}
#[test]
fn remove_clears_the_columns_and_keeps_the_files() {
let dir = tempdir().unwrap();
let conn = crate::db::open_in_memory().unwrap();
let asset = import_logo_from_base64(dir.path(), &STANDARD.encode(padded_png())).unwrap();
conn.execute(
"UPDATE app_settings SET logo_path = ?1, logo_original_path = ?2, logo_knockout_path = ?3,
logo_meta = ?4, logo_includes_name = 'yes'",
params![asset.print_path, asset.original_path, asset.knockout_path, metrics_json(&asset.metrics).unwrap()],
)
.unwrap();
remove_logo_impl(&conn).unwrap();
assert_eq!(logo_columns(&conn), (None, None, None, String::new()));
let settings = read_settings(&conn).unwrap();
assert_eq!(settings.logo_includes_name, "auto");
assert!(dir.path().join(&asset.print_path).is_file());
assert!(dir.path().join(&asset.original_path).is_file());
}
}