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
+30
View File
@@ -78,6 +78,24 @@ fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result<String, Stri
Ok(format!("assets/{file}"))
}
/// Write `assets/<file_name>` unless it already exists. For content-addressed derivatives, where the
/// same name always means the same bytes. The name must be a plain `[a-z0-9._-]` file name.
pub fn write_named_asset(data_dir: &Path, file_name: &str, bytes: &[u8]) -> Result<String, String> {
let plain = !file_name.is_empty()
&& !file_name.starts_with('.')
&& file_name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-' | '_'));
if !plain {
return Err(format!("Invalid asset name: {file_name}"));
}
let target = assets_dir(data_dir)?.join(file_name);
if !target.exists() {
std::fs::write(&target, bytes).map_err(|e| e.to_string())?;
}
Ok(format!("assets/{file_name}"))
}
fn asset_data_uri(data_dir: &Path, path: &str) -> Result<String, String> {
let file = resolve_asset(data_dir, path)?;
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
@@ -162,6 +180,18 @@ mod tests {
assert!(uri.starts_with("data:image/jpeg;base64,"));
}
#[test]
fn named_assets_are_plain_names_and_not_overwritten() {
let dir = tempdir().unwrap();
let stored = write_named_asset(dir.path(), "logo-ab12cd34-print.png", b"one").unwrap();
assert_eq!(stored, "assets/logo-ab12cd34-print.png");
write_named_asset(dir.path(), "logo-ab12cd34-print.png", b"two").unwrap();
assert_eq!(std::fs::read(dir.path().join(&stored)).unwrap(), b"one");
for bad in ["", ".hidden", "../x.png", "a/b.png", "A.png", "x y.png"] {
assert!(write_named_asset(dir.path(), bad, b"x").is_err(), "{bad}");
}
}
#[test]
fn data_uri_mime_comes_from_the_bytes_not_the_extension() {
let dir = tempdir().unwrap();
+8 -1
View File
@@ -209,6 +209,9 @@ fn vendor_snapshot(settings: &Settings) -> String {
"vendorGstin": gstin,
"vendorStateCode": settings.vendor_state_code,
"logoPath": settings.logo_path,
"logoKnockoutPath": settings.logo_knockout_path,
"logoMeta": settings.logo_meta,
"logoIncludesName": settings.logo_includes_name,
"signaturePath": settings.signature_path,
"gstRegistration": settings.gst_registration,
"signatoryName": settings.signatory_name,
@@ -760,7 +763,8 @@ mod tests {
let mut conn = registered();
conn.execute(
"UPDATE app_settings SET vendor_name = 'Real Name', logo_path = 'assets/logo-1.png',
signatory_name = 'A Signer', signatory_designation = 'Partner'",
logo_knockout_path = 'assets/logo-1-knockout.png', logo_meta = '{\"kind\":\"wordmark\"}',
logo_includes_name = 'yes', signatory_name = 'A Signer', signatory_designation = 'Partner'",
[],
)
.unwrap();
@@ -779,6 +783,9 @@ mod tests {
assert_eq!(snap["vendorGstin"], "27AAPFU0939F1ZV");
assert_eq!(snap["vendorStateCode"], "27");
assert_eq!(snap["logoPath"], "assets/logo-1.png");
assert_eq!(snap["logoKnockoutPath"], "assets/logo-1-knockout.png");
assert_eq!(snap["logoMeta"], "{\"kind\":\"wordmark\"}");
assert_eq!(snap["logoIncludesName"], "yes");
assert!(snap["signaturePath"].is_null());
assert_eq!(snap["gstRegistration"], "regular");
assert_eq!(snap["signatoryName"], "A Signer");
+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());
}
}
+1
View File
@@ -3,6 +3,7 @@ pub mod assets;
pub mod clients;
pub mod files;
pub mod invoice;
pub mod logo;
pub mod raw;
pub mod series;
pub mod settings;
+45 -2
View File
@@ -6,7 +6,8 @@ use tauri::State;
pub(crate) const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name,
signatory_designation, render_prefs, last_export_dir";
signatory_designation, render_prefs, last_export_dir, logo_original_path, logo_knockout_path, logo_meta,
logo_includes_name";
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
Ok(Settings {
@@ -32,6 +33,10 @@ pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
signatory_designation: row.get(19)?,
render_prefs: row.get(20)?,
last_export_dir: row.get(21)?,
logo_original_path: row.get(22)?,
logo_knockout_path: row.get(23)?,
logo_meta: row.get(24)?,
logo_includes_name: row.get(25)?,
})
}
@@ -48,6 +53,9 @@ pub fn get_settings(state: State<AppState>) -> Result<Settings, String> {
#[tauri::command]
pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Settings, String> {
if !matches!(settings.logo_includes_name.as_str(), "auto" | "yes" | "no") {
return Err("Logo includes name must be auto, yes or no".into());
}
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute(
r#"UPDATE app_settings SET
@@ -56,7 +64,9 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
onboarded = ?15, theme = ?16, gst_registration = ?17, default_hsn_sac = ?18,
signatory_name = ?19, signatory_designation = ?20, render_prefs = ?21
signatory_name = ?19, signatory_designation = ?20, render_prefs = ?21,
logo_original_path = ?22, logo_knockout_path = ?23, logo_meta = ?24,
logo_includes_name = ?25
WHERE id = 1"#,
params![
settings.vendor_name,
@@ -80,6 +90,10 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
settings.signatory_name,
settings.signatory_designation,
settings.render_prefs,
settings.logo_original_path,
settings.logo_knockout_path,
settings.logo_meta,
settings.logo_includes_name,
],
)
.map_err(|e| e.to_string())?;
@@ -253,6 +267,35 @@ mod tests {
assert_eq!(json["renderPrefs"], "{\"version\":1}");
}
#[test]
fn settings_carry_logo_columns() {
let conn = crate::db::open_in_memory().unwrap();
let read = |conn: &Connection| {
conn.query_row(
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
[],
map_settings,
)
.unwrap()
};
let fresh = read(&conn);
assert_eq!(fresh.logo_original_path, None);
assert_eq!(fresh.logo_knockout_path, None);
assert_eq!(fresh.logo_meta, "");
assert_eq!(fresh.logo_includes_name, "auto");
conn.execute(
"UPDATE app_settings SET logo_original_path = 'assets/o.png', logo_knockout_path = 'assets/k.png',
logo_meta = '{}', logo_includes_name = 'no' WHERE id = 1",
[],
)
.unwrap();
let json = serde_json::to_value(read(&conn)).unwrap();
assert_eq!(json["logoOriginalPath"], "assets/o.png");
assert_eq!(json["logoKnockoutPath"], "assets/k.png");
assert_eq!(json["logoMeta"], "{}");
assert_eq!(json["logoIncludesName"], "no");
}
#[test]
fn deleting_the_default_bank_promotes_the_lowest_remaining() {
let mut conn = crate::db::open_in_memory().unwrap();