321 lines
12 KiB
Rust
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());
|
|
}
|
|
}
|