diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4dc1269..7c7a6ae 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -340,6 +340,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -1703,6 +1709,32 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2089,6 +2121,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.20.0" @@ -2700,6 +2742,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.42.0" @@ -4236,6 +4290,7 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "chrono", + "image", "percent-encoding", "rusqlite", "rusqlite_migration", @@ -5138,6 +5193,21 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.15.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 17a2e79..c0a5dc5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ thiserror = "2" base64 = "0.22" sha2 = "0.10" percent-encoding = "2" +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } # Desktop-only plugins (not available on mobile targets). [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] diff --git a/src-tauri/src/commands/assets.rs b/src-tauri/src/commands/assets.rs index 009fb99..06d5c65 100644 --- a/src-tauri/src/commands/assets.rs +++ b/src-tauri/src/commands/assets.rs @@ -78,6 +78,24 @@ fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result` 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 { + 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 { 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(); diff --git a/src-tauri/src/commands/invoice.rs b/src-tauri/src/commands/invoice.rs index 1efcfeb..788af96 100644 --- a/src-tauri/src/commands/invoice.rs +++ b/src-tauri/src/commands/invoice.rs @@ -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"); diff --git a/src-tauri/src/commands/logo.rs b/src-tauri/src/commands/logo.rs new file mode 100644 index 0000000..424f791 --- /dev/null +++ b/src-tauri/src/commands/logo.rs @@ -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, + 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) -> Result { + 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 { + 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 { + // 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 { + 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 { + let (logo_path, meta): (Option, 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 { + 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, source_path: String) -> Result { + 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, + #[allow(unused_variables)] file_name: String, + data_base64: String, +) -> Result { + import_logo_from_base64(&state.data_dir, &data_base64) +} + +#[tauri::command] +pub fn ensure_logo_derived(state: State) -> Result { + 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) -> Result { + 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 { + 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, Option, Option, 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()); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e0316c5..a976e93 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 10042a8..4976570 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -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 { Ok(Settings { @@ -32,6 +33,10 @@ pub(crate) fn map_settings(row: &Row) -> rusqlite::Result { 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) -> Result { #[tauri::command] pub fn save_settings(state: State, settings: Settings) -> Result { + 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, settings: Settings) -> Result, settings: Settings) -> Result Migrations<'static> { - Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4)]) + Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5)]) } /// Open (creating if needed) the database at `path` and bring it to the latest schema. @@ -585,6 +594,42 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); assert_eq!(stored, json); } + #[test] + fn logo_columns_default_and_existing_logo_is_untouched() { + let conn = open_in_memory().unwrap(); + let row: (Option, Option, String, String) = conn + .query_row( + "SELECT logo_original_path, logo_knockout_path, logo_meta, logo_includes_name + FROM app_settings WHERE id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .unwrap(); + assert_eq!(row, (None, None, String::new(), "auto".to_string())); + + // A database at v4 with a logo keeps it; only the new columns appear. + let dir = tempdir().unwrap(); + let path = dir.path().join("voiced.db"); + { + let mut old = Connection::open(&path).unwrap(); + Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4)]) + .to_latest(&mut old) + .unwrap(); + seed(&old).unwrap(); + old.execute("UPDATE app_settings SET logo_path = 'assets/logo-old.png'", []).unwrap(); + assert_eq!(user_version(&old), 4); + } + let conn = open(&path, &dir.path().join("backups")).unwrap(); + assert_eq!(user_version(&conn), 5); + let (logo, meta): (String, String) = conn + .query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .unwrap(); + assert_eq!((logo.as_str(), meta.as_str()), ("assets/logo-old.png", "")); + assert_eq!(backups_in(&dir.path().join("backups")).len(), 1); + } + fn build_v1_database(path: &Path) { let conn = Connection::open(path).unwrap(); conn.execute_batch(V1_SCHEMA).unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 87f9d94..f88a43f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod db; mod gst; +mod logo; mod models; mod selftest; @@ -66,6 +67,10 @@ fn init_state(app: &tauri::App) -> Result> show_startup_error(app, &format!("Could not update stored image paths: {e}"), Some(&data_dir)); return Err(e.into()); } + // Best effort: a logo that cannot be derived keeps working untrimmed, and the next start retries. + if let Err(e) = commands::logo::ensure_logo_derived_impl(&conn, &data_dir) { + eprintln!("Could not derive the logo images: {e}"); + } Ok(AppState { db: Mutex::new(conn), data_dir, @@ -129,6 +134,10 @@ pub fn run() { commands::assets::save_asset_bytes, commands::assets::read_asset_data_uri, commands::assets::remove_asset, + commands::logo::import_logo, + commands::logo::save_logo_bytes, + commands::logo::ensure_logo_derived, + commands::logo::remove_logo, commands::archive::archive_pdf, commands::archive::read_archive, commands::archive::archive_status, diff --git a/src-tauri/src/logo.rs b/src-tauri/src/logo.rs new file mode 100644 index 0000000..c433152 --- /dev/null +++ b/src-tauri/src/logo.rs @@ -0,0 +1,549 @@ +//! Logo import pipeline: decode, measure the ink box, trim, downscale and derive the print and +//! knockout PNGs. Pure functions over bytes; storage lives in `commands::logo`. +use image::codecs::png::{CompressionType, FilterType, PngEncoder}; +use image::imageops::{self, FilterType as ResizeFilter}; +use image::{DynamicImage, ImageFormat, ImageReader, RgbImage, RgbaImage}; +use serde::{Deserialize, Serialize}; +use std::io::Cursor; + +/// Largest accepted input file. +pub const MAX_INPUT_BYTES: usize = 20 * 1024 * 1024; +/// Largest accepted side of the decoded image, in pixels. +const MAX_SIDE: u32 = 8192; +/// The longer side of the derived print image never exceeds this. +const PRINT_MAX_SIDE: u32 = 1200; +/// Pixels with alpha at or below this are not ink (alpha logos). +const INK_ALPHA: u8 = 32; +/// An image counts as having transparency when any pixel is more transparent than this. +const ALPHA_OPAQUE: u8 = 250; +/// Alpha at or below this is treated as fully transparent when cleaning the print image. +const ALPHA_EMPTY: u8 = 5; +/// Colour distance from the background under which an opaque pixel is "empty". +const BG_DISTANCE: i32 = 24; +/// Rows and columns with fewer ink pixels than this are noise and do not widen the trim box. +const MIN_LINE_INK: usize = 2; + +#[derive(Debug, thiserror::Error)] +pub enum LogoError { + #[error("Use a PNG, JPEG or WebP image")] + Format, + #[error("The image is larger than 20 MB")] + TooLarge, + #[error("The image is larger than {MAX_SIDE} pixels on a side")] + Dimensions, + #[error("Could not read the image: {0}")] + Decode(String), + #[error("The image has no visible content")] + Empty, + #[error("Could not encode the logo: {0}")] + Encode(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LogoKind { + Wordmark, + Mark, + Tall, +} + +/// Measurements of the trimmed, downscaled print image. Stored as JSON in `app_settings.logo_meta`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LogoMetrics { + pub width: u32, + pub height: u32, + pub aspect: f64, + pub ink_density: f64, + pub mean_color: [u8; 3], + pub kind: LogoKind, + pub has_alpha: bool, + pub opaque_background: bool, + pub source_width: u32, + pub source_height: u32, +} + +#[derive(Debug)] +pub struct LogoDerived { + pub print_png: Vec, + /// White ink with the original alpha; only for logos that have transparency. + pub knockout_png: Option>, + pub metrics: LogoMetrics, +} + +/// File extension for a sniffed original: png, jpg or webp. +pub fn sniff_extension(bytes: &[u8]) -> Result<&'static str, LogoError> { + sniff(bytes).map(|(_, ext)| ext) +} + +fn sniff(bytes: &[u8]) -> Result<(ImageFormat, &'static str), LogoError> { + if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { + Ok((ImageFormat::Png, "png")) + } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + Ok((ImageFormat::Jpeg, "jpg")) + } else if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + Ok((ImageFormat::WebP, "webp")) + } else { + Err(LogoError::Format) + } +} + +fn decode(bytes: &[u8]) -> Result { + if bytes.len() > MAX_INPUT_BYTES { + return Err(LogoError::TooLarge); + } + let (format, _) = sniff(bytes)?; + let decode_err = |e: image::ImageError| LogoError::Decode(e.to_string()); + let decoder = ImageReader::with_format(Cursor::new(bytes), format) + .into_decoder() + .map_err(decode_err)?; + // Check the declared size before allocating the pixels. + let (w, h) = image::ImageDecoder::dimensions(&decoder); + if w > MAX_SIDE || h > MAX_SIDE { + return Err(LogoError::Dimensions); + } + DynamicImage::from_decoder(decoder) + .map(|img| img.to_rgba8()) + .map_err(decode_err) +} + +/// Median of each channel over the four 4x4 corner patches. +fn corner_background(img: &RgbaImage) -> [u8; 3] { + let (w, h) = img.dimensions(); + let (pw, ph) = (4.min(w), 4.min(h)); + let mut channels: [Vec; 3] = Default::default(); + for (x0, y0) in [(0, 0), (w - pw, 0), (0, h - ph), (w - pw, h - ph)] { + for y in y0..y0 + ph { + for x in x0..x0 + pw { + let p = img.get_pixel(x, y).0; + for c in 0..3 { + channels[c].push(p[c]); + } + } + } + } + let mut out = [0u8; 3]; + for c in 0..3 { + channels[c].sort_unstable(); + out[c] = channels[c][channels[c].len() / 2]; + } + out +} + +/// What counts as ink: opaque enough for alpha logos, far enough from the background otherwise. +#[derive(Clone, Copy)] +enum InkTest { + Alpha, + Background([u8; 3]), +} + +impl InkTest { + fn is_ink(self, p: &[u8; 4]) -> bool { + match self { + InkTest::Alpha => p[3] > INK_ALPHA, + InkTest::Background(bg) => { + let d: i32 = (0..3).map(|c| (p[c] as i32 - bg[c] as i32).pow(2)).sum(); + d > BG_DISTANCE * BG_DISTANCE + } + } + } +} + +/// First and last index whose count reaches `MIN_LINE_INK`. +fn span(counts: &[usize]) -> Option<(usize, usize)> { + let first = counts.iter().position(|&n| n >= MIN_LINE_INK)?; + let last = counts.iter().rposition(|&n| n >= MIN_LINE_INK)?; + Some((first, last)) +} + +fn premultiply(img: &mut RgbaImage) { + for p in img.pixels_mut() { + let a = p.0[3] as u32; + for c in 0..3 { + p.0[c] = ((p.0[c] as u32 * a + 127) / 255) as u8; + } + } +} + +fn unpremultiply(img: &mut RgbaImage) { + for p in img.pixels_mut() { + let a = p.0[3] as u32; + if a == 0 { + continue; + } + for c in 0..3 { + p.0[c] = ((p.0[c] as u32 * 255 + a / 2) / a).min(255) as u8; + } + } +} + +/// Lanczos3 downscale to at most `PRINT_MAX_SIDE`. Colours are premultiplied while resampling so +/// transparent pixels do not bleed their (arbitrary) colour into the edges; the result is straight alpha. +fn downscale(img: RgbaImage, has_alpha: bool) -> RgbaImage { + let (w, h) = img.dimensions(); + let longer = w.max(h); + if longer <= PRINT_MAX_SIDE { + return img; + } + let scale = PRINT_MAX_SIDE as f64 / longer as f64; + let nw = ((w as f64 * scale).round() as u32).clamp(1, PRINT_MAX_SIDE); + let nh = ((h as f64 * scale).round() as u32).clamp(1, PRINT_MAX_SIDE); + let mut src = img; + if has_alpha { + premultiply(&mut src); + } + let mut out = imageops::resize(&src, nw, nh, ResizeFilter::Lanczos3); + if has_alpha { + unpremultiply(&mut out); + } + out +} + +/// Exporters leave faint noise in "solid" and "empty" pixels (alpha 253 of 255, hidden colour under +/// alpha 0). It is invisible but triples the PNG size, so snap near-solid alpha to solid, near-empty +/// to empty, and drop the colour of empty pixels. +fn clean_alpha(img: &mut RgbaImage) { + for p in img.pixels_mut() { + match p.0[3] { + a if a >= ALPHA_OPAQUE => p.0[3] = 255, + a if a <= ALPHA_EMPTY => p.0 = [0, 0, 0, 0], + _ => {} + } + } +} + +/// Keep 64 levels per channel (steps of about 4/255, invisible in print). Anti-aliasing and encoder +/// noise of +-1 otherwise makes flat colours incompressible: the Test Vendor logo drops from 149 KB to 75 KB. +fn quantize_colour(img: &mut RgbaImage) { + for p in img.pixels_mut() { + for c in 0..3 { + p.0[c] = ((((p.0[c] as u32 * 63 + 127) / 255) * 255 + 31) / 63) as u8; + } + } +} + +fn encode_png(img: &DynamicImage) -> Result, LogoError> { + let mut buf = Vec::new(); + let encoder = PngEncoder::new_with_quality(&mut buf, CompressionType::Best, FilterType::Adaptive); + img.write_with_encoder(encoder) + .map_err(|e| LogoError::Encode(e.to_string()))?; + Ok(buf) +} + +fn kind_for(aspect: f64) -> LogoKind { + if aspect >= 2.5 { + LogoKind::Wordmark + } else if aspect <= 0.6 { + LogoKind::Tall + } else { + LogoKind::Mark + } +} + +/// Decode a logo, trim it to its ink box, and derive the print PNG, the knockout PNG and the metrics. +pub fn process_logo(bytes: &[u8]) -> Result { + let source = decode(bytes)?; + let (source_width, source_height) = source.dimensions(); + let has_alpha = source.pixels().any(|p| p.0[3] < ALPHA_OPAQUE); + // The background estimate only drives measuring; no pixel is edited. + let test = if has_alpha { + InkTest::Alpha + } else { + InkTest::Background(corner_background(&source)) + }; + + let mut rows = vec![0usize; source_height as usize]; + let mut cols = vec![0usize; source_width as usize]; + for (x, y, p) in source.enumerate_pixels() { + if test.is_ink(&p.0) { + rows[y as usize] += 1; + cols[x as usize] += 1; + } + } + let (Some((top, bottom)), Some((left, right))) = (span(&rows), span(&cols)) else { + return Err(LogoError::Empty); + }; + + let cropped = imageops::crop_imm( + &source, + left as u32, + top as u32, + (right - left + 1) as u32, + (bottom - top + 1) as u32, + ) + .to_image(); + let mut print = downscale(cropped, has_alpha); + if has_alpha { + clean_alpha(&mut print); + } + quantize_colour(&mut print); + let (width, height) = print.dimensions(); + + let (mut ink, mut sum) = (0u64, [0u64; 3]); + for p in print.pixels() { + if test.is_ink(&p.0) { + ink += 1; + for (total, channel) in sum.iter_mut().zip(p.0) { + *total += channel as u64; + } + } + } + let mean_color = if ink == 0 { + [0; 3] + } else { + [0, 1, 2].map(|c| ((sum[c] + ink / 2) / ink) as u8) + }; + let aspect = width as f64 / height as f64; + + let knockout_png = if has_alpha { + let mut white = print.clone(); + for p in white.pixels_mut() { + p.0[0] = 255; + p.0[1] = 255; + p.0[2] = 255; + } + Some(encode_png(&DynamicImage::ImageRgba8(white))?) + } else { + None + }; + let print_image = if has_alpha { + DynamicImage::ImageRgba8(print) + } else { + // No transparency to keep: RGB is smaller. + DynamicImage::ImageRgb8(RgbImage::from_fn(width, height, |x, y| { + let p = print.get_pixel(x, y).0; + image::Rgb([p[0], p[1], p[2]]) + })) + }; + + Ok(LogoDerived { + print_png: encode_png(&print_image)?, + knockout_png, + metrics: LogoMetrics { + width, + height, + aspect, + ink_density: ink as f64 / (width as f64 * height as f64), + mean_color, + kind: kind_for(aspect), + has_alpha, + opaque_background: !has_alpha, + source_width, + source_height, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{codecs::jpeg::JpegEncoder, codecs::webp::WebPEncoder, ExtendedColorType, Rgba}; + + fn png_bytes(img: &RgbaImage) -> Vec { + let mut out = Vec::new(); + DynamicImage::ImageRgba8(img.clone()) + .write_to(&mut Cursor::new(&mut out), ImageFormat::Png) + .unwrap(); + out + } + + /// Transparent canvas with an opaque rectangle. + fn alpha_logo(w: u32, h: u32, rect: (u32, u32, u32, u32), color: [u8; 3]) -> RgbaImage { + let mut img = RgbaImage::from_pixel(w, h, Rgba([0, 0, 0, 0])); + let (x0, y0, rw, rh) = rect; + for y in y0..y0 + rh { + for x in x0..x0 + rw { + img.put_pixel(x, y, Rgba([color[0], color[1], color[2], 255])); + } + } + img + } + + fn decode_png(bytes: &[u8]) -> RgbaImage { + image::load_from_memory_with_format(bytes, ImageFormat::Png).unwrap().to_rgba8() + } + + #[test] + fn alpha_logo_is_trimmed_to_the_rect_without_padding() { + let img = alpha_logo(200, 100, (30, 20, 60, 25), [10, 20, 30]); + let out = process_logo(&png_bytes(&img)).unwrap(); + let m = &out.metrics; + assert_eq!((m.width, m.height), (60, 25)); + assert_eq!((m.source_width, m.source_height), (200, 100)); + assert!(m.has_alpha && !m.opaque_background); + let printed = decode_png(&out.print_png); + assert_eq!(printed.dimensions(), (60, 25)); + // Every edge pixel row/column is ink: nothing was left as padding. + assert!(printed.pixels().all(|p| p.0[3] == 255)); + // Colours are quantised to 64 levels, so allow a few steps. + for (got, want) in m.mean_color.iter().zip([10i32, 20, 30]) { + assert!((*got as i32 - want).abs() <= 3, "{:?}", m.mean_color); + } + } + + #[test] + fn noise_specks_do_not_widen_the_box() { + let mut img = alpha_logo(200, 100, (50, 30, 40, 20), [0, 0, 0]); + // Isolated single pixels far from the logo. + img.put_pixel(2, 2, Rgba([0, 0, 0, 255])); + img.put_pixel(197, 97, Rgba([0, 0, 0, 255])); + img.put_pixel(100, 3, Rgba([0, 0, 0, 255])); + let m = process_logo(&png_bytes(&img)).unwrap().metrics; + assert_eq!((m.width, m.height), (40, 20)); + } + + #[test] + fn faint_alpha_is_not_ink() { + let mut img = alpha_logo(100, 100, (40, 40, 20, 20), [0, 0, 0]); + // A soft shadow below the alpha threshold. + for y in 70..90 { + for x in 10..90 { + img.put_pixel(x, y, Rgba([0, 0, 0, 30])); + } + } + let m = process_logo(&png_bytes(&img)).unwrap().metrics; + assert_eq!((m.width, m.height), (20, 20)); + } + + #[test] + fn opaque_white_background_jpeg_is_trimmed_and_flagged() { + let mut img = RgbaImage::from_pixel(160, 120, Rgba([255, 255, 255, 255])); + for y in 40..80 { + for x in 30..130 { + img.put_pixel(x, y, Rgba([20, 40, 160, 255])); + } + } + let rgb = DynamicImage::ImageRgba8(img).to_rgb8(); + let mut jpg = Vec::new(); + JpegEncoder::new_with_quality(&mut jpg, 95) + .encode(rgb.as_raw(), 160, 120, ExtendedColorType::Rgb8) + .unwrap(); + + let out = process_logo(&jpg).unwrap(); + let m = &out.metrics; + assert!(m.opaque_background && !m.has_alpha); + // JPEG ringing may move an edge by a pixel or two. + assert!((98..=102).contains(&m.width), "{}", m.width); + assert!((38..=42).contains(&m.height), "{}", m.height); + assert!(out.knockout_png.is_none()); + // The input pixels are not edited: the printed corners are still the logo colour, not transparent. + let printed = image::load_from_memory_with_format(&out.print_png, ImageFormat::Png).unwrap(); + assert!(!printed.color().has_alpha()); + } + + #[test] + fn lossless_webp_is_accepted() { + let img = alpha_logo(80, 60, (10, 10, 30, 30), [200, 0, 0]); + let mut webp = Vec::new(); + WebPEncoder::new_lossless(&mut webp) + .encode(img.as_raw(), 80, 60, ExtendedColorType::Rgba8) + .unwrap(); + assert_eq!(sniff_extension(&webp).unwrap(), "webp"); + let m = process_logo(&webp).unwrap().metrics; + assert_eq!((m.width, m.height), (30, 30)); + assert!(m.has_alpha); + } + + #[test] + fn other_formats_and_garbage_are_rejected() { + let msg = "Use a PNG, JPEG or WebP image"; + for bad in [ + &b"GIF89a\x01\x00\x01\x00"[..], + b"", + b"not an image at all", + b"", + b"RIFF\x00\x00\x00\x00WAVEfmt ", + ] { + assert_eq!(process_logo(bad).unwrap_err().to_string(), msg); + } + // Right magic, broken body. + let mut truncated = png_bytes(&alpha_logo(20, 20, (2, 2, 5, 5), [0; 3])); + truncated.truncate(30); + assert!(matches!(process_logo(&truncated), Err(LogoError::Decode(_)))); + } + + #[test] + fn oversized_input_and_dimensions_are_rejected() { + let mut big = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + big.resize(MAX_INPUT_BYTES + 1, 0); + assert!(matches!(process_logo(&big), Err(LogoError::TooLarge))); + + // A blank image 8193 px wide compresses to a few KB, so it passes the byte cap. + let wide = RgbaImage::new(8193, 2); + assert!(matches!(process_logo(&png_bytes(&wide)), Err(LogoError::Dimensions))); + } + + #[test] + fn blank_images_have_no_content() { + assert!(matches!(process_logo(&png_bytes(&RgbaImage::new(40, 40))), Err(LogoError::Empty))); + let white = RgbaImage::from_pixel(40, 40, Rgba([255, 255, 255, 255])); + assert!(matches!(process_logo(&png_bytes(&white)), Err(LogoError::Empty))); + } + + #[test] + fn large_logos_are_downscaled_to_1200() { + let img = alpha_logo(3000, 1000, (10, 10, 2980, 980), [0, 0, 0]); + let out = process_logo(&png_bytes(&img)).unwrap(); + let m = &out.metrics; + assert_eq!((m.width, m.height), (1200, 395)); + assert_eq!((m.source_width, m.source_height), (3000, 1000)); + assert_eq!(decode_png(&out.print_png).dimensions(), (1200, 395)); + assert!((m.aspect - 2980.0 / 980.0).abs() < 0.01); + } + + #[test] + fn density_aspect_and_kind() { + // Half of a 100 x 40 box is ink: left half filled. + let img = alpha_logo(100, 40, (0, 0, 50, 40), [0, 0, 0]); + // Trimming shrinks the box to the ink, so add a second block to keep the density at 0.5. + let mut img = img; + for y in 0..40 { + img.put_pixel(99, y, Rgba([0, 0, 0, 255])); + img.put_pixel(98, y, Rgba([0, 0, 0, 255])); + } + let m = process_logo(&png_bytes(&img)).unwrap().metrics; + assert_eq!((m.width, m.height), (100, 40)); + assert!((m.aspect - 2.5).abs() < 1e-9); + assert_eq!(m.kind, LogoKind::Wordmark); + assert!((m.ink_density - 0.52).abs() < 1e-9, "{}", m.ink_density); + + let square = alpha_logo(50, 50, (10, 10, 30, 30), [0; 3]); + assert_eq!(process_logo(&png_bytes(&square)).unwrap().metrics.kind, LogoKind::Mark); + let tall = alpha_logo(50, 100, (10, 10, 12, 40), [0; 3]); + assert_eq!(process_logo(&png_bytes(&tall)).unwrap().metrics.kind, LogoKind::Tall); + assert_eq!(kind_for(2.49), LogoKind::Mark); + assert_eq!(kind_for(0.6), LogoKind::Tall); + } + + #[test] + fn knockout_is_white_with_the_original_alpha() { + let mut img = alpha_logo(60, 40, (5, 5, 40, 20), [30, 60, 90]); + // Anti-aliased edge column with partial alpha. + for y in 5..25 { + img.put_pixel(44, y, Rgba([30, 60, 90, 128])); + } + let out = process_logo(&png_bytes(&img)).unwrap(); + let print = decode_png(&out.print_png); + let knock = decode_png(&out.knockout_png.expect("alpha logo has a knockout")); + assert_eq!(print.dimensions(), knock.dimensions()); + for (a, b) in print.pixels().zip(knock.pixels()) { + assert_eq!(&b.0[..3], &[255, 255, 255]); + assert_eq!(a.0[3], b.0[3]); + } + assert!(knock.pixels().any(|p| p.0[3] == 128)); + } + + #[test] + fn sample_logo_is_trimmed_and_small() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/brand/sample-wordmark-logo.png"); + let bytes = std::fs::read(path).unwrap(); + let out = process_logo(&bytes).unwrap(); + let m = &out.metrics; + assert_eq!((m.source_width, m.source_height), (2172, 724)); + assert!((4.5..=4.7).contains(&m.aspect), "aspect {}", m.aspect); + assert!((0.40..=0.46).contains(&m.ink_density), "density {}", m.ink_density); + assert_eq!(m.kind, LogoKind::Wordmark); + assert!(out.print_png.len() <= 120 * 1024, "print is {} bytes", out.print_png.len()); + assert!(out.knockout_png.is_some()); + } +} diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 7ec9abe..73df5c2 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -29,6 +29,22 @@ pub struct Settings { /// Folder of the last export; owned by the backend, so saving settings never changes it. #[serde(default)] pub last_export_dir: String, + /// The untouched logo file the derived images came from. + #[serde(default)] + pub logo_original_path: Option, + /// White-ink variant for dark surfaces; only for logos with transparency. + #[serde(default)] + pub logo_knockout_path: Option, + /// LogoMetrics JSON; empty until the logo has been through the import pipeline. + #[serde(default)] + pub logo_meta: String, + /// Whether the logo already contains the business name: `auto`, `yes` or `no`. + #[serde(default = "default_logo_includes_name")] + pub logo_includes_name: String, +} + +fn default_logo_includes_name() -> String { + "auto".to_string() } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/components/LogoBranding.tsx b/src/components/LogoBranding.tsx new file mode 100644 index 0000000..deb9147 --- /dev/null +++ b/src/components/LogoBranding.tsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from "react"; +import { Button, InlineLoading, InlineNotification, RadioButton, RadioButtonGroup } from "@carbon/react"; +import { TrashCan, Upload } from "@carbon/icons-react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { api } from "../lib/api"; +import { + loadLogoImages, + logoFacts, + logoIncludesName, + parseLogoMeta, + type LogoAsset, + type LogoImages, + type LogoIncludesName, + type LogoSettings, +} from "../lib/logo"; +import type { Settings } from "../lib/types"; + +type LogoPatch = Pick; + +interface LogoBrandingProps { + /** Id prefix, so two instances on a page never share control ids. */ + idPrefix: string; + value: LogoSettings; + onChange: (patch: Partial) => void; + disabled?: boolean; +} + +/** + * Logo upload with the pipeline's result: the trimmed logo on a white and on a dark surface, what + * was measured, and whether the logo already contains the business name. + */ +export function LogoBranding({ idPrefix, value, onChange, disabled }: LogoBrandingProps) { + const [images, setImages] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + if (!value.logoPath) { + setImages(null); + return; + } + loadLogoImages(value, api.readAssetDataUri) + .then((loaded) => active && setImages(loaded)) + .catch(() => active && setImages(null)); + return () => { + active = false; + }; + }, [value.logoPath, value.logoKnockoutPath]); + + const pick = async () => { + setError(null); + try { + const selected = await open({ + multiple: false, + directory: false, + filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp"] }], + }); + if (typeof selected !== "string") return; + setBusy(true); + const asset: LogoAsset = await api.importLogo(selected); + onChange({ + logoPath: asset.printPath, + logoOriginalPath: asset.originalPath, + logoKnockoutPath: asset.knockoutPath, + logoMeta: JSON.stringify(asset.metrics), + }); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + const remove = async () => { + setError(null); + try { + await api.removeLogo(); + onChange({ logoPath: null, logoOriginalPath: null, logoKnockoutPath: null, logoMeta: "", logoIncludesName: "auto" }); + } catch (e) { + setError(String(e)); + } + }; + + const meta = parseLogoMeta(value.logoMeta); + const automaticYes = logoIncludesName("auto", meta?.kind ?? null); + + return ( +
+
Vendor logo (PNG, JPEG or WebP). A transparent PNG works best.
+ + {value.logoPath ? ( +
+
+
+ {images ? Logo on a white background : } +
+
On white
+
+
+
+ {images ? ( + images.knockoutUri ? ( + White logo on a dark background + ) : ( + + Logo on a white chip over a dark background + + ) + ) : ( + + )} +
+
+ {images && !images.knockoutUri ? "On dark (white chip)" : "On dark"} +
+
+
+ ) : ( +
{busy ? : "No logo selected"}
+ )} + + {meta ?
{logoFacts(meta)}
: null} + + {meta?.opaqueBackground ? ( + + ) : null} + +
+ + {value.logoPath ? ( + + ) : null} + {busy && value.logoPath ? : null} +
+ + {error ? : null} + + {value.logoPath ? ( + onChange({ logoIncludesName: next as LogoIncludesName })} + > + + + + + ) : null} +
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index eec34fb..b1e98b9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -9,6 +9,7 @@ import type { InvoiceSummary, Settings, } from "./types"; +import type { LogoAsset } from "./logo"; export interface ArchiveStatus { archived: boolean; @@ -52,6 +53,11 @@ export const api = { invoke("import_asset", { sourcePath, kind }), saveAssetBytes: (kind: string, fileName: string, dataBase64: string) => invoke("save_asset_bytes", { kind, fileName, dataBase64 }), + importLogo: (sourcePath: string) => invoke("import_logo", { sourcePath }), + saveLogoBytes: (fileName: string, dataBase64: string) => + invoke("save_logo_bytes", { fileName, dataBase64 }), + ensureLogoDerived: () => invoke("ensure_logo_derived"), + removeLogo: () => invoke("remove_logo"), readAssetDataUri: (path: string) => invoke("read_asset_data_uri", { path }), removeAsset: (path: string) => invoke("remove_asset", { path }), diff --git a/src/lib/logo.test.ts b/src/lib/logo.test.ts new file mode 100644 index 0000000..82949bc --- /dev/null +++ b/src/lib/logo.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from "vitest"; +import { vendorFromSnapshot } from "../pdf/model/vendor"; +import { loadLogoImages, logoFacts, logoForModel, logoIncludesName, parseLogoMeta, type LogoMeta } from "./logo"; +import type { Settings } from "./types"; + +const meta: LogoMeta = { + width: 1200, + height: 261, + aspect: 4.6, + inkDensity: 0.43, + meanColor: [13, 35, 63], + kind: "wordmark", + hasAlpha: true, + opaqueBackground: false, + sourceWidth: 2172, + sourceHeight: 724, +}; + +const vendor = (over: Partial = {}) => + ({ logoPath: "assets/p.png", logoKnockoutPath: "assets/k.png", logoMeta: JSON.stringify(meta), logoIncludesName: "auto", ...over }) as Settings; + +// 1 x 1 PNG header with a 40 x 10 size, enough for imageSizeFromDataUri. +const png40x10 = () => { + const b = new Uint8Array(24); + b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + b[19] = 40; + b[23] = 10; + return `data:image/png;base64,${btoa(String.fromCharCode(...b))}`; +}; + +describe("parseLogoMeta", () => { + it("round-trips the backend JSON", () => { + expect(parseLogoMeta(JSON.stringify(meta))).toEqual(meta); + }); + it("returns null for empty, malformed or incomplete metrics", () => { + expect(parseLogoMeta("")).toBeNull(); + expect(parseLogoMeta(null)).toBeNull(); + expect(parseLogoMeta("{nope")).toBeNull(); + expect(parseLogoMeta("null")).toBeNull(); + expect(parseLogoMeta("{}")).toBeNull(); + expect(parseLogoMeta(JSON.stringify({ ...meta, kind: "blob" }))).toBeNull(); + expect(parseLogoMeta(JSON.stringify({ ...meta, aspect: 0 }))).toBeNull(); + }); +}); + +describe("logoIncludesName", () => { + it("resolves Automatic from the kind", () => { + expect(logoIncludesName("auto", "wordmark")).toBe(true); + expect(logoIncludesName("auto", "mark")).toBe(false); + expect(logoIncludesName("auto", null)).toBe(false); + expect(logoIncludesName("yes", "mark")).toBe(true); + expect(logoIncludesName("no", "wordmark")).toBe(false); + }); +}); + +describe("logoForModel", () => { + it("uses the stored metrics and the knockout when the logo has alpha", () => { + const logo = logoForModel(vendor(), { printUri: "data:p", knockoutUri: "data:k" }); + expect(logo).toEqual({ + dataUri: "data:p", + width: 1200, + height: 261, + aspect: 4.6, + density: 0.43, + kind: "wordmark", + includesName: true, + hasKnockout: true, + knockoutDataUri: "data:k", + }); + }); + it("has no knockout for an opaque logo or when the file is missing", () => { + const opaque = JSON.stringify({ ...meta, hasAlpha: false, opaqueBackground: true }); + expect(logoForModel(vendor({ logoMeta: opaque }), { printUri: "data:p", knockoutUri: "data:k" })).toMatchObject({ hasKnockout: false }); + const missing = logoForModel(vendor(), { printUri: "data:p", knockoutUri: null }); + expect(missing?.hasKnockout).toBe(false); + expect(missing).not.toHaveProperty("knockoutDataUri"); + }); + it("honours the includes-name override", () => { + expect(logoForModel(vendor({ logoIncludesName: "no" }), { printUri: "p", knockoutUri: null })?.includesName).toBe(false); + }); + it("falls back to the image size for a legacy logo without metrics", () => { + const logo = logoForModel(vendor({ logoMeta: "" }), { printUri: png40x10(), knockoutUri: "data:k" }); + expect(logo).toMatchObject({ width: 40, height: 10, aspect: 4, kind: "wordmark", includesName: true, hasKnockout: false }); + }); + it("is null without images", () => { + expect(logoForModel(vendor(), null)).toBeNull(); + }); +}); + +describe("loadLogoImages", () => { + it("reads print and knockout, tolerating a missing knockout", async () => { + const read = vi.fn(async (p: string) => { + if (p === "assets/k.png") throw new Error("gone"); + return `uri:${p}`; + }); + expect(await loadLogoImages(vendor(), read)).toEqual({ printUri: "uri:assets/p.png", knockoutUri: null }); + expect(await loadLogoImages(vendor({ logoPath: null }), read)).toBeNull(); + }); +}); + +describe("logoFacts", () => { + it("summarises the logo", () => { + expect(logoFacts(meta)).toBe("Wordmark • 4.6:1 • transparent background"); + expect(logoFacts({ ...meta, kind: "tall", aspect: 0.5, hasAlpha: false })).toBe("Tall logo • 1:2.0 • solid background"); + }); +}); + +describe("vendorFromSnapshot logo keys", () => { + const live = vendor({ logoMeta: JSON.stringify(meta), logoKnockoutPath: "assets/live-k.png", logoIncludesName: "yes" }); + it("does not lend live logo metrics to a snapshot from before the pipeline", () => { + const old = vendorFromSnapshot(JSON.stringify({ vendorName: "A", logoPath: "assets/old.png" }), live); + expect(old.logoPath).toBe("assets/old.png"); + expect(old.logoMeta).toBe(""); + expect(old.logoKnockoutPath).toBeNull(); + expect(old.logoIncludesName).toBe("auto"); + }); + it("uses the frozen values when present", () => { + const snap = JSON.stringify({ logoPath: "assets/p.png", logoKnockoutPath: "assets/k.png", logoMeta: "{}", logoIncludesName: "no" }); + expect(vendorFromSnapshot(snap, live)).toMatchObject({ logoKnockoutPath: "assets/k.png", logoMeta: "{}", logoIncludesName: "no" }); + }); +}); diff --git a/src/lib/logo.ts b/src/lib/logo.ts new file mode 100644 index 0000000..55b5da8 --- /dev/null +++ b/src/lib/logo.ts @@ -0,0 +1,128 @@ +import type { LogoInput } from "../pdf/model/build"; +import { imageSizeFromDataUri } from "./imageSize"; +import type { Settings } from "./types"; + +export type LogoKind = "wordmark" | "mark" | "tall"; +export type LogoIncludesName = "auto" | "yes" | "no"; + +/** The metrics the Rust logo pipeline stores as JSON in `settings.logoMeta`. */ +export interface LogoMeta { + width: number; + height: number; + aspect: number; + inkDensity: number; + meanColor: [number, number, number]; + kind: LogoKind; + hasAlpha: boolean; + opaqueBackground: boolean; + sourceWidth: number; + sourceHeight: number; +} + +/** What the backend returns for an imported logo (paths are relative to the data dir). */ +export interface LogoAsset { + originalPath: string; + printPath: string; + knockoutPath: string | null; + metrics: LogoMeta; +} + +export type LogoSettings = Pick; + +/** The loaded print image and, when the logo has transparency, its white knockout. */ +export interface LogoImages { + printUri: string; + knockoutUri: string | null; +} + +/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */ +const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 }; +/** Ink density assumed for a logo that never went through the pipeline. */ +const UNKNOWN_INK_DENSITY = 0.5; + +const KINDS: readonly string[] = ["wordmark", "mark", "tall"]; + +const isPositive = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n > 0; + +export function parseLogoMeta(json: string | null | undefined): LogoMeta | null { + if (!json) return null; + try { + const m = JSON.parse(json) as Partial | null; + if (!m || typeof m !== "object") return null; + if (!isPositive(m.width) || !isPositive(m.height) || !isPositive(m.aspect)) return null; + if (typeof m.inkDensity !== "number" || !Number.isFinite(m.inkDensity)) return null; + if (typeof m.kind !== "string" || !KINDS.includes(m.kind)) return null; + return { + width: m.width, + height: m.height, + aspect: m.aspect, + inkDensity: m.inkDensity, + meanColor: Array.isArray(m.meanColor) && m.meanColor.length === 3 ? (m.meanColor as [number, number, number]) : [0, 0, 0], + kind: m.kind as LogoKind, + hasAlpha: Boolean(m.hasAlpha), + opaqueBackground: Boolean(m.opaqueBackground), + sourceWidth: Number(m.sourceWidth) || m.width, + sourceHeight: Number(m.sourceHeight) || m.height, + }; + } catch { + return null; + } +} + +/** "Automatic" means a wordmark already carries the business name. */ +export function logoIncludesName(setting: string | undefined, kind: LogoKind | null): boolean { + if (setting === "yes") return true; + if (setting === "no") return false; + return kind === "wordmark"; +} + +/** The render-model logo from the loaded images and the stored metrics, or from the image itself for a legacy logo. */ +export function logoForModel(vendor: LogoSettings, images: LogoImages | null): LogoInput | null { + if (!images) return null; + const meta = parseLogoMeta(vendor.logoMeta); + const knockoutDataUri = meta?.hasAlpha ? images.knockoutUri : null; + const hasKnockout = Boolean(knockoutDataUri); + if (meta) { + return { + dataUri: images.printUri, + width: meta.width, + height: meta.height, + aspect: meta.aspect, + density: meta.inkDensity, + kind: meta.kind, + includesName: logoIncludesName(vendor.logoIncludesName, meta.kind), + hasKnockout, + ...(hasKnockout ? { knockoutDataUri: knockoutDataUri as string } : {}), + }; + } + const size = imageSizeFromDataUri(images.printUri) ?? UNKNOWN_LOGO_SIZE; + const aspect = size.width / size.height; + const kind: LogoKind = aspect >= 2.5 ? "wordmark" : aspect <= 0.6 ? "tall" : "mark"; + return { + dataUri: images.printUri, + ...size, + aspect, + density: UNKNOWN_INK_DENSITY, + kind, + includesName: logoIncludesName(vendor.logoIncludesName, kind), + hasKnockout: false, + }; +} + +/** Loads the print and knockout images a vendor's logo settings point at. A missing knockout is not an error. */ +export async function loadLogoImages( + vendor: Pick, + read: (path: string) => Promise, +): Promise { + if (!vendor.logoPath) return null; + const printUri = await read(vendor.logoPath); + const knockoutUri = vendor.logoKnockoutPath ? await read(vendor.logoKnockoutPath).catch(() => null) : null; + return { printUri, knockoutUri }; +} + +/** "Wordmark • 4.6:1 • transparent background" */ +export function logoFacts(meta: LogoMeta): string { + const kind = { wordmark: "Wordmark", mark: "Mark", tall: "Tall logo" }[meta.kind]; + const ratio = meta.aspect >= 1 ? `${meta.aspect.toFixed(1)}:1` : `1:${(1 / meta.aspect).toFixed(1)}`; + return [kind, ratio, meta.hasAlpha ? "transparent background" : "solid background"].join(" • "); +} diff --git a/src/lib/pdf.tsx b/src/lib/pdf.tsx index 9d2df3b..8d3b28c 100644 --- a/src/lib/pdf.tsx +++ b/src/lib/pdf.tsx @@ -5,13 +5,10 @@ import { vendorFromSnapshot } from "../pdf/model/vendor"; import { Superseded } from "../pdf/render/client"; import type { RenderResult } from "../pdf/render/jobs"; import { api } from "./api"; -import { imageSizeFromDataUri } from "./imageSize"; +import { loadLogoImages, logoForModel, type LogoImages } from "./logo"; import { getRenderClient, nextRenderJobId } from "./renderClient"; import type { BankAccount, Invoice, Settings } from "./types"; -/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */ -const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 }; - /** * The render model for an invoice whose images are already loaded (the live preview caches them). * Supplier and bank come from the snapshots frozen at issue; live settings are only a fallback. @@ -20,14 +17,14 @@ export function modelFromAssets( invoice: Invoice, settings: Settings, bank: BankAccount | null, - logoUri: string | null, + logoImages: LogoImages | null, signatureDataUri: string | null, ): RenderModel { return buildRenderModel({ invoice, vendor: settings, bank, - logo: logoUri ? { dataUri: logoUri, ...(imageSizeFromDataUri(logoUri) ?? UNKNOWN_LOGO_SIZE) } : null, + logo: logoForModel(vendorFromSnapshot(invoice.vendorSnapshot, settings), logoImages), signatureDataUri, appVersion: pkg.version, }); @@ -36,9 +33,9 @@ export function modelFromAssets( /** Loads the invoice's logo and signature, then builds its render model. */ export async function buildModelForInvoice(invoice: Invoice, settings: Settings, bank: BankAccount | null): Promise { const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings); - const logoUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null; + const logoImages = await loadLogoImages(vendor, api.readAssetDataUri); const signatureDataUri = invoice.signaturePath ? await api.readAssetDataUri(invoice.signaturePath) : null; - return modelFromAssets(invoice, settings, bank, logoUri, signatureDataUri); + return modelFromAssets(invoice, settings, bank, logoImages, signatureDataUri); } /** Page setup for an invoice: what was frozen at issue, else the user's current default. */ diff --git a/src/lib/previewInvoice.ts b/src/lib/previewInvoice.ts index 9cbebdd..d6cb35f 100644 --- a/src/lib/previewInvoice.ts +++ b/src/lib/previewInvoice.ts @@ -40,6 +40,9 @@ export function vendorSnapshotJson(settings: Settings): string { vendorGstin: settings.gstRegistration === "unregistered" ? "" : settings.vendorGstin.trim(), vendorStateCode: settings.vendorStateCode, logoPath: settings.logoPath, + logoKnockoutPath: settings.logoKnockoutPath, + logoMeta: settings.logoMeta, + logoIncludesName: settings.logoIncludesName, signaturePath: settings.signaturePath, gstRegistration: settings.gstRegistration, signatoryName: settings.signatoryName, diff --git a/src/lib/types.ts b/src/lib/types.ts index 7be1352..a50a06e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -27,6 +27,14 @@ export interface Settings { renderPrefs: string; /** Folder of the last export; kept by the backend (saving settings does not change it). */ lastExportDir?: string; + /** The untouched logo file the derived images came from. */ + logoOriginalPath: string | null; + /** White-ink variant for dark surfaces; only for logos with transparency. */ + logoKnockoutPath: string | null; + /** LogoMeta JSON from the import pipeline; empty until the logo has been through it. */ + logoMeta: string; + /** Whether the logo already contains the business name. Automatic means a wordmark does. */ + logoIncludesName: "auto" | "yes" | "no"; } export interface BankAccount { diff --git a/src/pdf/model/build.test.ts b/src/pdf/model/build.test.ts index 53cfd9c..e6506ef 100644 --- a/src/pdf/model/build.test.ts +++ b/src/pdf/model/build.test.ts @@ -12,6 +12,10 @@ const vendor: Settings = { vendorGstin: "", vendorStateCode: "29", logoPath: null, + logoOriginalPath: null, + logoKnockoutPath: null, + logoMeta: "", + logoIncludesName: "auto", signaturePath: null, defaultBankId: 1, defaultTaxRate: 18, @@ -229,7 +233,17 @@ describe("cancelled and logo", () => { expect(make({ status: "draft" }).cancelled).toBe(false); }); it("passes the logo and signature through, and keeps null when absent", () => { - const logo = { dataUri: "data:image/png;base64,AA==", width: 200, height: 40 }; + const logo = { + dataUri: "data:image/png;base64,AA==", + width: 200, + height: 40, + aspect: 5, + density: 0.43, + kind: "wordmark" as const, + includesName: true, + hasKnockout: true, + knockoutDataUri: "data:image/png;base64,CC==", + }; const m = make({}, {}, { logo, signatureDataUri: "data:image/png;base64,BB==" }); expect(m.logo).toEqual(logo); expect(m.signature.dataUri).toBe("data:image/png;base64,BB=="); diff --git a/src/pdf/model/build.ts b/src/pdf/model/build.ts index 083d85b..9deaaff 100644 --- a/src/pdf/model/build.ts +++ b/src/pdf/model/build.ts @@ -11,9 +11,21 @@ import { sanitize, splitLines } from "./text"; import { bankFromSnapshot, vendorFromSnapshot } from "./vendor"; export interface LogoInput { + /** The trimmed print image. */ dataUri: string; + /** Pixel size of `dataUri`. */ width: number; height: number; + /** Width / height of the ink box. */ + aspect: number; + /** Share of the box that is ink, 0 to 1. */ + density: number; + kind: "wordmark" | "mark" | "tall"; + /** The logo already carries the business name (the user's setting, resolved from Automatic). */ + includesName: boolean; + /** A white-ink variant exists, for dark surfaces. */ + hasKnockout?: boolean; + knockoutDataUri?: string; } export interface BuildInput { diff --git a/src/pdf/model/vendor.ts b/src/pdf/model/vendor.ts index bf7a3ee..156d81f 100644 --- a/src/pdf/model/vendor.ts +++ b/src/pdf/model/vendor.ts @@ -7,7 +7,15 @@ import type { BankAccount, Settings } from "../../lib/types"; export function vendorFromSnapshot(snapshot: string | undefined, live: Settings): Settings { if (!snapshot) return live; try { - return { ...live, ...(JSON.parse(snapshot) as Partial) }; + const frozen = JSON.parse(snapshot) as Partial; + // Snapshots from before the logo pipeline lack these keys; the live values describe a different logo. + return { + ...live, + logoKnockoutPath: null, + logoMeta: "", + logoIncludesName: "auto", + ...frozen, + }; } catch { return live; } diff --git a/src/pdf/templates/harness.ts b/src/pdf/templates/harness.ts index 6b65f99..f04a51c 100644 --- a/src/pdf/templates/harness.ts +++ b/src/pdf/templates/harness.ts @@ -38,6 +38,10 @@ const baseVendor: Settings = { vendorGstin: "", vendorStateCode: "29", logoPath: null, + logoOriginalPath: null, + logoKnockoutPath: null, + logoMeta: "", + logoIncludesName: "auto", signaturePath: null, defaultBankId: 1, defaultTaxRate: 18, @@ -136,12 +140,13 @@ export function fixtureLogo(kind: LogoKind): LogoInput | null { if (kind === "real") { // 2172 x 724: the untrimmed Test Vendor logo, a 3:1 aspect. const png = readFileSync(path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png")); - hit = { dataUri: dataUri(png), width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; + const [width, height] = [png.readUInt32BE(16), png.readUInt32BE(20)]; + hit = { dataUri: dataUri(png), width, height, aspect: width / height, density: 0.21, kind: "wordmark", includesName: true }; } else if (kind === "wide") { // The trimmed Test Vendor ink box is 4.6:1. - hit = { dataUri: dataUri(solidPng(460, 100)), width: 460, height: 100 }; + hit = { dataUri: dataUri(solidPng(460, 100)), width: 460, height: 100, aspect: 4.6, density: 1, kind: "wordmark", includesName: true }; } else { - hit = { dataUri: dataUri(solidPng(120, 120)), width: 120, height: 120 }; + hit = { dataUri: dataUri(solidPng(120, 120)), width: 120, height: 120, aspect: 1, density: 1, kind: "mark", includesName: false }; } cache.set(kind, hit); } diff --git a/src/pdf/testing/selfTestFixtures.ts b/src/pdf/testing/selfTestFixtures.ts index 0c5999d..9397212 100644 --- a/src/pdf/testing/selfTestFixtures.ts +++ b/src/pdf/testing/selfTestFixtures.ts @@ -28,6 +28,10 @@ const VENDOR: Settings = { vendorGstin: "", vendorStateCode: "29", logoPath: null, + logoOriginalPath: null, + logoKnockoutPath: null, + logoMeta: "", + logoIncludesName: "auto", signaturePath: null, defaultBankId: 1, defaultTaxRate: 18, diff --git a/src/styles/carbon.scss b/src/styles/carbon.scss index 6dca5c1..c872803 100644 --- a/src/styles/carbon.scss +++ b/src/styles/carbon.scss @@ -396,3 +396,59 @@ body { white-space: pre-wrap; overflow-wrap: anywhere; } + +/* ---------- Logo preview (Settings -> Branding) ---------- */ +.voiced-logo-preview { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.voiced-logo-preview__item { + margin: 0; + width: 14rem; +} + +.voiced-logo-preview__surface { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + height: 6rem; + padding: 1rem; + border: 1px solid var(--cds-border-subtle-01); + overflow: hidden; + + img { + display: block; + max-width: 100%; + max-height: 4rem; + object-fit: contain; + } +} + +.voiced-logo-preview__surface--light { + background: #ffffff; +} + +.voiced-logo-preview__surface--dark { + background: #161616; +} + +.voiced-logo-preview__chip { + display: flex; + box-sizing: border-box; + max-width: 100%; + padding: 0.5rem 0.75rem; + background: #ffffff; + border-radius: 0.5rem; + + img { + max-height: 3rem; + } +} + +.voiced-logo-preview__item figcaption { + margin-top: 0.5rem; + font-size: 0.75rem; +} diff --git a/src/views/AppSettings.tsx b/src/views/AppSettings.tsx index 32f36c3..99eb9f8 100644 --- a/src/views/AppSettings.tsx +++ b/src/views/AppSettings.tsx @@ -19,6 +19,7 @@ import { TableRow, TextArea, TextInput, + Tile, Toggle, } from "@carbon/react"; import { Add, Save, TrashCan } from "@carbon/icons-react"; @@ -33,6 +34,7 @@ import { } from "../lib/types"; import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators"; import { ImagePicker } from "../components/ImagePicker"; +import { LogoBranding } from "../components/LogoBranding"; import DiagnosticsPanel from "../components/DiagnosticsPanel"; const blankBank = (): BankAccount => ({ @@ -264,14 +266,6 @@ export default function AppSettings({ onChange={(e) => set("signatoryDesignation", e.target.value)} /> - - set("logoPath", path)} - /> - +
+

Branding

+ + setForm((prev) => ({ ...prev, ...patch }))} + disabled={busy} + /> + +
+

Defaults

diff --git a/src/views/NewInvoice.tsx b/src/views/NewInvoice.tsx index 083fab3..488de69 100644 --- a/src/views/NewInvoice.tsx +++ b/src/views/NewInvoice.tsx @@ -65,6 +65,7 @@ import { loadExportMode } from "../lib/exportMode"; import type { ExportMode } from "../lib/filename"; import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs"; import { ImagePicker } from "../components/ImagePicker"; +import { loadLogoImages, type LogoImages } from "../lib/logo"; // pdf.js is only needed once a preview exists, so it loads on demand. const PdfPreview = lazy(() => import("../components/PdfPreview")); @@ -135,7 +136,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv const [signaturePath, setSignaturePath] = useState(settings.signaturePath); const [notes, setNotes] = useState(""); - const [logoUri, setLogoUri] = useState(null); + const [logoImages, setLogoImages] = useState(null); const [signatureUri, setSignatureUri] = useState(null); const [saving, setSaving] = useState(false); @@ -196,14 +197,16 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv useEffect(() => { let active = true; if (!settings.logoPath) { - setLogoUri(null); + setLogoImages(null); return; } - api.readAssetDataUri(settings.logoPath).then((uri) => active && setLogoUri(uri)).catch(() => {}); + loadLogoImages(settings, api.readAssetDataUri) + .then((images) => active && setLogoImages(images)) + .catch(() => {}); return () => { active = false; }; - }, [settings.logoPath]); + }, [settings.logoPath, settings.logoKnockoutPath]); useEffect(() => { let active = true; @@ -365,8 +368,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv settings, bank, ); - return modelFromAssets(invoice, settings, bank, logoUri, signatureUri); - }, [active, issued, invoiceDate, dueDate, clientId, clientName, clientAddress, clientGstin, poNumber, placeOfSupply, discount, taxRate, reverseCharge, signaturePath, notes, items, number, settings, bank, logoUri, signatureUri]); + return modelFromAssets(invoice, settings, bank, logoImages, signatureUri); + }, [active, issued, invoiceDate, dueDate, clientId, clientName, clientAddress, clientGstin, poNumber, placeOfSupply, discount, taxRate, reverseCharge, signaturePath, notes, items, number, settings, bank, logoImages, signatureUri]); const updateItem = (index: number, patch: Partial) => setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); diff --git a/src/views/Onboarding.tsx b/src/views/Onboarding.tsx index 18f8f4c..83ca89c 100644 --- a/src/views/Onboarding.tsx +++ b/src/views/Onboarding.tsx @@ -19,6 +19,7 @@ import type { BankAccount, GstRegistration, Settings, TaxType } from "../lib/typ import { INDIAN_STATES } from "../lib/types"; import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators"; import { ImagePicker } from "../components/ImagePicker"; +import { LogoBranding } from "../components/LogoBranding"; interface OnboardingProps { initial: Settings; @@ -219,11 +220,11 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) { content: ( - set("logoPath", path)} + setForm((prev) => ({ ...prev, ...patch }))} + disabled={saving} />