From bd83326d54d185ff3c67ca0c25d18fa8b2e046bf Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Sun, 4 Oct 2026 06:31:30 +0530 Subject: [PATCH] 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). --- src-tauri/Cargo.lock | 70 ++++ src-tauri/Cargo.toml | 1 + src-tauri/src/commands/assets.rs | 30 ++ src-tauri/src/commands/invoice.rs | 9 +- src-tauri/src/commands/logo.rs | 270 ++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/settings.rs | 47 ++- src-tauri/src/db.rs | 49 ++- src-tauri/src/lib.rs | 9 + src-tauri/src/logo.rs | 549 ++++++++++++++++++++++++++++ src-tauri/src/models.rs | 16 + src/components/LogoBranding.tsx | 165 +++++++++ src/lib/api.ts | 6 + src/lib/logo.test.ts | 121 ++++++ src/lib/logo.ts | 128 +++++++ src/lib/pdf.tsx | 13 +- src/lib/previewInvoice.ts | 3 + src/lib/types.ts | 8 + src/pdf/model/build.test.ts | 16 +- src/pdf/model/build.ts | 12 + src/pdf/model/vendor.ts | 10 +- src/pdf/templates/harness.ts | 11 +- src/pdf/testing/selfTestFixtures.ts | 4 + src/styles/carbon.scss | 56 +++ src/views/AppSettings.tsx | 22 +- src/views/NewInvoice.tsx | 15 +- src/views/Onboarding.tsx | 11 +- 27 files changed, 1615 insertions(+), 37 deletions(-) create mode 100644 src-tauri/src/commands/logo.rs create mode 100644 src-tauri/src/logo.rs create mode 100644 src/components/LogoBranding.tsx create mode 100644 src/lib/logo.test.ts create mode 100644 src/lib/logo.ts 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} />