Files
Voiced/src-tauri/src/commands/logo.rs
T
soubarna b6cf46b883 style(src-tauri): cargo fmt
Pure mechanical rustfmt reformat (default config, no rustfmt.toml).
2026-10-05 17:19:13 +05:30

321 lines
12 KiB
Rust

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());
}
}