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 Arun P logo PDF shrinks from 334 KB to 92 KB. Settings gains a Branding section with white and dark previews and a business-name toggle (migration M5).
This commit is contained in:
Generated
+70
@@ -340,6 +340,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
@@ -1703,6 +1709,32 @@ dependencies = [
|
|||||||
"icu_properties",
|
"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]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@@ -2089,6 +2121,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.20.0"
|
version = "0.20.0"
|
||||||
@@ -2700,6 +2742,18 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.42.0"
|
version = "0.42.0"
|
||||||
@@ -4236,6 +4290,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"image",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rusqlite_migration",
|
"rusqlite_migration",
|
||||||
@@ -5138,6 +5193,21 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
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]]
|
[[package]]
|
||||||
name = "zvariant"
|
name = "zvariant"
|
||||||
version = "5.15.0"
|
version = "5.15.0"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ thiserror = "2"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
|
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||||
|
|
||||||
# Desktop-only plugins (not available on mobile targets).
|
# Desktop-only plugins (not available on mobile targets).
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
|
|||||||
@@ -78,6 +78,24 @@ fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result<String, Stri
|
|||||||
Ok(format!("assets/{file}"))
|
Ok(format!("assets/{file}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write `assets/<file_name>` unless it already exists. For content-addressed derivatives, where the
|
||||||
|
/// same name always means the same bytes. The name must be a plain `[a-z0-9._-]` file name.
|
||||||
|
pub fn write_named_asset(data_dir: &Path, file_name: &str, bytes: &[u8]) -> Result<String, String> {
|
||||||
|
let plain = !file_name.is_empty()
|
||||||
|
&& !file_name.starts_with('.')
|
||||||
|
&& file_name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-' | '_'));
|
||||||
|
if !plain {
|
||||||
|
return Err(format!("Invalid asset name: {file_name}"));
|
||||||
|
}
|
||||||
|
let target = assets_dir(data_dir)?.join(file_name);
|
||||||
|
if !target.exists() {
|
||||||
|
std::fs::write(&target, bytes).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(format!("assets/{file_name}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn asset_data_uri(data_dir: &Path, path: &str) -> Result<String, String> {
|
fn asset_data_uri(data_dir: &Path, path: &str) -> Result<String, String> {
|
||||||
let file = resolve_asset(data_dir, path)?;
|
let file = resolve_asset(data_dir, path)?;
|
||||||
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
|
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,"));
|
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]
|
#[test]
|
||||||
fn data_uri_mime_comes_from_the_bytes_not_the_extension() {
|
fn data_uri_mime_comes_from_the_bytes_not_the_extension() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
|
|||||||
@@ -209,6 +209,9 @@ fn vendor_snapshot(settings: &Settings) -> String {
|
|||||||
"vendorGstin": gstin,
|
"vendorGstin": gstin,
|
||||||
"vendorStateCode": settings.vendor_state_code,
|
"vendorStateCode": settings.vendor_state_code,
|
||||||
"logoPath": settings.logo_path,
|
"logoPath": settings.logo_path,
|
||||||
|
"logoKnockoutPath": settings.logo_knockout_path,
|
||||||
|
"logoMeta": settings.logo_meta,
|
||||||
|
"logoIncludesName": settings.logo_includes_name,
|
||||||
"signaturePath": settings.signature_path,
|
"signaturePath": settings.signature_path,
|
||||||
"gstRegistration": settings.gst_registration,
|
"gstRegistration": settings.gst_registration,
|
||||||
"signatoryName": settings.signatory_name,
|
"signatoryName": settings.signatory_name,
|
||||||
@@ -760,7 +763,8 @@ mod tests {
|
|||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE app_settings SET vendor_name = 'Real Name', logo_path = 'assets/logo-1.png',
|
"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();
|
.unwrap();
|
||||||
@@ -779,6 +783,9 @@ mod tests {
|
|||||||
assert_eq!(snap["vendorGstin"], "27AAPFU0939F1ZV");
|
assert_eq!(snap["vendorGstin"], "27AAPFU0939F1ZV");
|
||||||
assert_eq!(snap["vendorStateCode"], "27");
|
assert_eq!(snap["vendorStateCode"], "27");
|
||||||
assert_eq!(snap["logoPath"], "assets/logo-1.png");
|
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!(snap["signaturePath"].is_null());
|
||||||
assert_eq!(snap["gstRegistration"], "regular");
|
assert_eq!(snap["gstRegistration"], "regular");
|
||||||
assert_eq!(snap["signatoryName"], "A Signer");
|
assert_eq!(snap["signatoryName"], "A Signer");
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
use super::assets::{relative_asset_path, resolve_asset, write_named_asset};
|
||||||
|
use super::settings::{map_settings, SETTINGS_COLS};
|
||||||
|
use crate::logo::{process_logo, sniff_extension, LogoMetrics, MAX_INPUT_BYTES};
|
||||||
|
use crate::models::Settings;
|
||||||
|
use crate::AppState;
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::path::Path;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
/// The stored files and measurements of an imported logo. Paths are relative to the data dir.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LogoAsset {
|
||||||
|
pub original_path: String,
|
||||||
|
pub print_path: String,
|
||||||
|
pub knockout_path: Option<String>,
|
||||||
|
pub metrics: LogoMetrics,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha8(bytes: &[u8]) -> String {
|
||||||
|
Sha256::digest(bytes).iter().take(4).map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the print and knockout images from `bytes` and store them content-addressed in the asset
|
||||||
|
/// store. `existing_original` is the already stored file the bytes came from (legacy logos); without
|
||||||
|
/// it the bytes are stored as the original. Nothing is ever deleted: issued invoices may still point at
|
||||||
|
/// older logo files.
|
||||||
|
fn store_logo(data_dir: &Path, bytes: &[u8], existing_original: Option<String>) -> Result<LogoAsset, String> {
|
||||||
|
let derived = process_logo(bytes).map_err(|e| e.to_string())?;
|
||||||
|
let id = sha8(bytes);
|
||||||
|
let original_path = match existing_original {
|
||||||
|
Some(path) => path,
|
||||||
|
None => {
|
||||||
|
let ext = sniff_extension(bytes).map_err(|e| e.to_string())?;
|
||||||
|
write_named_asset(data_dir, &format!("logo-{id}-original.{ext}"), bytes)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let print_path = write_named_asset(data_dir, &format!("logo-{id}-print.png"), &derived.print_png)?;
|
||||||
|
let knockout_path = derived
|
||||||
|
.knockout_png
|
||||||
|
.as_deref()
|
||||||
|
.map(|png| write_named_asset(data_dir, &format!("logo-{id}-knockout.png"), png))
|
||||||
|
.transpose()?;
|
||||||
|
Ok(LogoAsset { original_path, print_path, knockout_path, metrics: derived.metrics })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_logo_from_path(data_dir: &Path, source_path: &str) -> Result<LogoAsset, String> {
|
||||||
|
let len = std::fs::metadata(source_path).map_err(|e| e.to_string())?.len();
|
||||||
|
if len > MAX_INPUT_BYTES as u64 {
|
||||||
|
return Err(crate::logo::LogoError::TooLarge.to_string());
|
||||||
|
}
|
||||||
|
let bytes = std::fs::read(source_path).map_err(|e| e.to_string())?;
|
||||||
|
store_logo(data_dir, &bytes, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_logo_from_base64(data_dir: &Path, data_base64: &str) -> Result<LogoAsset, String> {
|
||||||
|
// Base64 is 4/3 of the payload; refuse before decoding something huge.
|
||||||
|
if data_base64.len() > MAX_INPUT_BYTES / 3 * 4 + 8 {
|
||||||
|
return Err(crate::logo::LogoError::TooLarge.to_string());
|
||||||
|
}
|
||||||
|
let bytes = STANDARD.decode(data_base64.as_bytes()).map_err(|e| e.to_string())?;
|
||||||
|
store_logo(data_dir, &bytes, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metrics_json(metrics: &LogoMetrics) -> Result<String, String> {
|
||||||
|
serde_json::to_string(metrics).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive trimmed print and knockout images for a logo saved before the pipeline existed
|
||||||
|
/// (`logo_path` set, `logo_meta` empty) and point the settings at them. The old file is kept as the
|
||||||
|
/// original. Returns whether anything changed.
|
||||||
|
pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result<bool, String> {
|
||||||
|
let (logo_path, meta): (Option<String>, String) = conn
|
||||||
|
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?))
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let Some(logo_path) = logo_path.filter(|p| !p.trim().is_empty()) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if !meta.is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let file = resolve_asset(data_dir, &logo_path)?;
|
||||||
|
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
|
||||||
|
let asset = store_logo(data_dir, &bytes, Some(relative_asset_path(data_dir, &logo_path)?))?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET logo_path = ?1, logo_original_path = ?2, logo_knockout_path = ?3,
|
||||||
|
logo_meta = ?4 WHERE id = 1",
|
||||||
|
params![asset.print_path, asset.original_path, asset.knockout_path, metrics_json(&asset.metrics)?],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detach the logo from the settings. The files stay: issued invoices may reference them.
|
||||||
|
pub fn remove_logo_impl(conn: &Connection) -> Result<(), String> {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET logo_path = NULL, logo_original_path = NULL, logo_knockout_path = NULL,
|
||||||
|
logo_meta = '', logo_includes_name = 'auto' WHERE id = 1",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_settings(conn: &Connection) -> Result<Settings, String> {
|
||||||
|
conn.query_row(
|
||||||
|
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
|
||||||
|
[],
|
||||||
|
map_settings,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn import_logo(state: State<AppState>, source_path: String) -> Result<LogoAsset, String> {
|
||||||
|
import_logo_from_path(&state.data_dir, &source_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For when the frontend already holds the bytes. The file name is ignored: the type comes from the bytes.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_logo_bytes(
|
||||||
|
state: State<AppState>,
|
||||||
|
#[allow(unused_variables)] file_name: String,
|
||||||
|
data_base64: String,
|
||||||
|
) -> Result<LogoAsset, String> {
|
||||||
|
import_logo_from_base64(&state.data_dir, &data_base64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn ensure_logo_derived(state: State<AppState>) -> Result<Settings, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
ensure_logo_derived_impl(&conn, &state.data_dir)?;
|
||||||
|
read_settings(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_logo(state: State<AppState>) -> Result<Settings, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
remove_logo_impl(&conn)?;
|
||||||
|
read_settings(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
|
||||||
|
use std::io::Cursor;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
/// A 300x100 transparent canvas with a 100x20 opaque bar off-centre.
|
||||||
|
fn padded_png() -> Vec<u8> {
|
||||||
|
let mut img = RgbaImage::new(300, 100);
|
||||||
|
for y in 40..60 {
|
||||||
|
for x in 150..250 {
|
||||||
|
img.put_pixel(x, y, Rgba([10, 20, 30, 255]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
DynamicImage::ImageRgba8(img).write_to(&mut Cursor::new(&mut out), ImageFormat::Png).unwrap();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logo_columns(conn: &Connection) -> (Option<String>, Option<String>, Option<String>, String) {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT logo_path, logo_original_path, logo_knockout_path, logo_meta FROM app_settings WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base64_import_stores_content_addressed_files() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let bytes = padded_png();
|
||||||
|
let asset = import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap();
|
||||||
|
let id = sha8(&bytes);
|
||||||
|
assert_eq!(asset.original_path, format!("assets/logo-{id}-original.png"));
|
||||||
|
assert_eq!(asset.print_path, format!("assets/logo-{id}-print.png"));
|
||||||
|
assert_eq!(asset.knockout_path.as_deref(), Some(format!("assets/logo-{id}-knockout.png").as_str()));
|
||||||
|
assert_eq!((asset.metrics.width, asset.metrics.height), (100, 20));
|
||||||
|
assert_eq!(std::fs::read(dir.path().join(&asset.original_path)).unwrap(), bytes);
|
||||||
|
for rel in [&asset.print_path, asset.knockout_path.as_ref().unwrap()] {
|
||||||
|
assert!(resolve_asset(dir.path(), rel).is_ok());
|
||||||
|
}
|
||||||
|
// The same bytes land on the same names.
|
||||||
|
assert_eq!(import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap().print_path, asset.print_path);
|
||||||
|
let json = serde_json::to_value(&asset).unwrap();
|
||||||
|
assert_eq!(json["metrics"]["kind"], "wordmark");
|
||||||
|
assert_eq!(json["metrics"]["opaqueBackground"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_import_reads_the_file_and_rejects_bad_input() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let src = tempdir().unwrap();
|
||||||
|
let file = src.path().join("brand.png");
|
||||||
|
std::fs::write(&file, padded_png()).unwrap();
|
||||||
|
let asset = import_logo_from_path(dir.path(), file.to_str().unwrap()).unwrap();
|
||||||
|
assert_eq!(asset.metrics.width, 100);
|
||||||
|
|
||||||
|
let gif = src.path().join("x.gif");
|
||||||
|
std::fs::write(&gif, b"GIF89a....").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
import_logo_from_path(dir.path(), gif.to_str().unwrap()).unwrap_err(),
|
||||||
|
"Use a PNG, JPEG or WebP image"
|
||||||
|
);
|
||||||
|
assert!(import_logo_from_path(dir.path(), "/nonexistent/logo.png").is_err());
|
||||||
|
assert!(import_logo_from_base64(dir.path(), "!!not base64!!").is_err());
|
||||||
|
assert!(import_logo_from_base64(dir.path(), &STANDARD.encode(b"junk")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_derives_a_legacy_untrimmed_logo_and_keeps_the_old_file() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let legacy = write_named_asset(dir.path(), "logo-legacy.png", &padded_png()).unwrap();
|
||||||
|
conn.execute("UPDATE app_settings SET logo_path = ?1", [&legacy]).unwrap();
|
||||||
|
|
||||||
|
assert!(ensure_logo_derived_impl(&conn, dir.path()).unwrap());
|
||||||
|
let (logo, original, knockout, meta) = logo_columns(&conn);
|
||||||
|
let logo = logo.unwrap();
|
||||||
|
assert!(logo.ends_with("-print.png"), "{logo}");
|
||||||
|
assert_eq!(original.as_deref(), Some(legacy.as_str()));
|
||||||
|
assert!(knockout.unwrap().ends_with("-knockout.png"));
|
||||||
|
let metrics: LogoMetrics = serde_json::from_str(&meta).unwrap();
|
||||||
|
assert_eq!((metrics.width, metrics.height), (100, 20));
|
||||||
|
assert!(dir.path().join(&legacy).is_file());
|
||||||
|
assert!(dir.path().join(&logo).is_file());
|
||||||
|
|
||||||
|
// Already derived: nothing to do the second time.
|
||||||
|
assert!(!ensure_logo_derived_impl(&conn, dir.path()).unwrap());
|
||||||
|
assert_eq!(logo_columns(&conn).0.as_deref(), Some(logo.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_does_nothing_without_a_logo_and_errors_on_a_missing_file() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
assert!(!ensure_logo_derived_impl(&conn, dir.path()).unwrap());
|
||||||
|
conn.execute("UPDATE app_settings SET logo_path = 'assets/gone.png'", []).unwrap();
|
||||||
|
assert!(ensure_logo_derived_impl(&conn, dir.path()).is_err());
|
||||||
|
// Left untouched so a later start can retry.
|
||||||
|
assert_eq!(logo_columns(&conn).0.as_deref(), Some("assets/gone.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_clears_the_columns_and_keeps_the_files() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let asset = import_logo_from_base64(dir.path(), &STANDARD.encode(padded_png())).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET logo_path = ?1, logo_original_path = ?2, logo_knockout_path = ?3,
|
||||||
|
logo_meta = ?4, logo_includes_name = 'yes'",
|
||||||
|
params![asset.print_path, asset.original_path, asset.knockout_path, metrics_json(&asset.metrics).unwrap()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
remove_logo_impl(&conn).unwrap();
|
||||||
|
assert_eq!(logo_columns(&conn), (None, None, None, String::new()));
|
||||||
|
let settings = read_settings(&conn).unwrap();
|
||||||
|
assert_eq!(settings.logo_includes_name, "auto");
|
||||||
|
assert!(dir.path().join(&asset.print_path).is_file());
|
||||||
|
assert!(dir.path().join(&asset.original_path).is_file());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub mod assets;
|
|||||||
pub mod clients;
|
pub mod clients;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod invoice;
|
pub mod invoice;
|
||||||
|
pub mod logo;
|
||||||
pub mod raw;
|
pub mod raw;
|
||||||
pub mod series;
|
pub mod series;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ use tauri::State;
|
|||||||
pub(crate) const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
|
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,
|
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,
|
default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name,
|
||||||
signatory_designation, render_prefs, last_export_dir";
|
signatory_designation, render_prefs, last_export_dir, logo_original_path, logo_knockout_path, logo_meta,
|
||||||
|
logo_includes_name";
|
||||||
|
|
||||||
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
||||||
Ok(Settings {
|
Ok(Settings {
|
||||||
@@ -32,6 +33,10 @@ pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
|||||||
signatory_designation: row.get(19)?,
|
signatory_designation: row.get(19)?,
|
||||||
render_prefs: row.get(20)?,
|
render_prefs: row.get(20)?,
|
||||||
last_export_dir: row.get(21)?,
|
last_export_dir: row.get(21)?,
|
||||||
|
logo_original_path: row.get(22)?,
|
||||||
|
logo_knockout_path: row.get(23)?,
|
||||||
|
logo_meta: row.get(24)?,
|
||||||
|
logo_includes_name: row.get(25)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +53,9 @@ pub fn get_settings(state: State<AppState>) -> Result<Settings, String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Settings, String> {
|
pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Settings, String> {
|
||||||
|
if !matches!(settings.logo_includes_name.as_str(), "auto" | "yes" | "no") {
|
||||||
|
return Err("Logo includes name must be auto, yes or no".into());
|
||||||
|
}
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
r#"UPDATE app_settings SET
|
r#"UPDATE app_settings SET
|
||||||
@@ -56,7 +64,9 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
|||||||
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
|
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
|
||||||
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
||||||
onboarded = ?15, theme = ?16, gst_registration = ?17, default_hsn_sac = ?18,
|
onboarded = ?15, theme = ?16, gst_registration = ?17, default_hsn_sac = ?18,
|
||||||
signatory_name = ?19, signatory_designation = ?20, render_prefs = ?21
|
signatory_name = ?19, signatory_designation = ?20, render_prefs = ?21,
|
||||||
|
logo_original_path = ?22, logo_knockout_path = ?23, logo_meta = ?24,
|
||||||
|
logo_includes_name = ?25
|
||||||
WHERE id = 1"#,
|
WHERE id = 1"#,
|
||||||
params![
|
params![
|
||||||
settings.vendor_name,
|
settings.vendor_name,
|
||||||
@@ -80,6 +90,10 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
|||||||
settings.signatory_name,
|
settings.signatory_name,
|
||||||
settings.signatory_designation,
|
settings.signatory_designation,
|
||||||
settings.render_prefs,
|
settings.render_prefs,
|
||||||
|
settings.logo_original_path,
|
||||||
|
settings.logo_knockout_path,
|
||||||
|
settings.logo_meta,
|
||||||
|
settings.logo_includes_name,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -253,6 +267,35 @@ mod tests {
|
|||||||
assert_eq!(json["renderPrefs"], "{\"version\":1}");
|
assert_eq!(json["renderPrefs"], "{\"version\":1}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_carry_logo_columns() {
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let read = |conn: &Connection| {
|
||||||
|
conn.query_row(
|
||||||
|
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
|
||||||
|
[],
|
||||||
|
map_settings,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let fresh = read(&conn);
|
||||||
|
assert_eq!(fresh.logo_original_path, None);
|
||||||
|
assert_eq!(fresh.logo_knockout_path, None);
|
||||||
|
assert_eq!(fresh.logo_meta, "");
|
||||||
|
assert_eq!(fresh.logo_includes_name, "auto");
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET logo_original_path = 'assets/o.png', logo_knockout_path = 'assets/k.png',
|
||||||
|
logo_meta = '{}', logo_includes_name = 'no' WHERE id = 1",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let json = serde_json::to_value(read(&conn)).unwrap();
|
||||||
|
assert_eq!(json["logoOriginalPath"], "assets/o.png");
|
||||||
|
assert_eq!(json["logoKnockoutPath"], "assets/k.png");
|
||||||
|
assert_eq!(json["logoMeta"], "{}");
|
||||||
|
assert_eq!(json["logoIncludesName"], "no");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deleting_the_default_bank_promotes_the_lowest_remaining() {
|
fn deleting_the_default_bank_promotes_the_lowest_remaining() {
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
|||||||
+47
-2
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
|
|||||||
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||||
|
|
||||||
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||||
const LATEST_VERSION: i64 = 4;
|
const LATEST_VERSION: i64 = 5;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum DbError {
|
pub enum DbError {
|
||||||
@@ -181,8 +181,17 @@ ALTER TABLE invoices ADD COLUMN archived_at TEXT;
|
|||||||
ALTER TABLE app_settings ADD COLUMN last_export_dir TEXT NOT NULL DEFAULT '';
|
ALTER TABLE app_settings ADD COLUMN last_export_dir TEXT NOT NULL DEFAULT '';
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Version 5: logo pipeline outputs. `logo_path` now points at the trimmed print image; the original
|
||||||
|
/// is kept so it can be re-derived. Invoice snapshots issued earlier simply lack the new keys.
|
||||||
|
const M5: &str = r#"
|
||||||
|
ALTER TABLE app_settings ADD COLUMN logo_original_path TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN logo_knockout_path TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN logo_meta TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE app_settings ADD COLUMN logo_includes_name TEXT NOT NULL DEFAULT 'auto';
|
||||||
|
"#;
|
||||||
|
|
||||||
fn migrations() -> Migrations<'static> {
|
fn migrations() -> 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.
|
/// 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);
|
assert_eq!(stored, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logo_columns_default_and_existing_logo_is_untouched() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let row: (Option<String>, Option<String>, 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) {
|
fn build_v1_database(path: &Path) {
|
||||||
let conn = Connection::open(path).unwrap();
|
let conn = Connection::open(path).unwrap();
|
||||||
conn.execute_batch(V1_SCHEMA).unwrap();
|
conn.execute_batch(V1_SCHEMA).unwrap();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod db;
|
mod db;
|
||||||
mod gst;
|
mod gst;
|
||||||
|
mod logo;
|
||||||
mod models;
|
mod models;
|
||||||
mod selftest;
|
mod selftest;
|
||||||
|
|
||||||
@@ -66,6 +67,10 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
show_startup_error(app, &format!("Could not update stored image paths: {e}"), Some(&data_dir));
|
show_startup_error(app, &format!("Could not update stored image paths: {e}"), Some(&data_dir));
|
||||||
return Err(e.into());
|
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 {
|
Ok(AppState {
|
||||||
db: Mutex::new(conn),
|
db: Mutex::new(conn),
|
||||||
data_dir,
|
data_dir,
|
||||||
@@ -129,6 +134,10 @@ pub fn run() {
|
|||||||
commands::assets::save_asset_bytes,
|
commands::assets::save_asset_bytes,
|
||||||
commands::assets::read_asset_data_uri,
|
commands::assets::read_asset_data_uri,
|
||||||
commands::assets::remove_asset,
|
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::archive_pdf,
|
||||||
commands::archive::read_archive,
|
commands::archive::read_archive,
|
||||||
commands::archive::archive_status,
|
commands::archive::archive_status,
|
||||||
|
|||||||
@@ -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<u8>,
|
||||||
|
/// White ink with the original alpha; only for logos that have transparency.
|
||||||
|
pub knockout_png: Option<Vec<u8>>,
|
||||||
|
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<RgbaImage, LogoError> {
|
||||||
|
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<u8>; 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 Arun P 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<Vec<u8>, 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<LogoDerived, LogoError> {
|
||||||
|
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<u8> {
|
||||||
|
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"<svg xmlns='http://www.w3.org/2000/svg'/>",
|
||||||
|
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 real_arun_p_logo_is_trimmed_and_small() {
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/brand/arun-p-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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,22 @@ pub struct Settings {
|
|||||||
/// Folder of the last export; owned by the backend, so saving settings never changes it.
|
/// Folder of the last export; owned by the backend, so saving settings never changes it.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_export_dir: String,
|
pub last_export_dir: String,
|
||||||
|
/// The untouched logo file the derived images came from.
|
||||||
|
#[serde(default)]
|
||||||
|
pub logo_original_path: Option<String>,
|
||||||
|
/// White-ink variant for dark surfaces; only for logos with transparency.
|
||||||
|
#[serde(default)]
|
||||||
|
pub logo_knockout_path: Option<String>,
|
||||||
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -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<Settings, "logoPath" | "logoOriginalPath" | "logoKnockoutPath" | "logoMeta" | "logoIncludesName">;
|
||||||
|
|
||||||
|
interface LogoBrandingProps {
|
||||||
|
/** Id prefix, so two instances on a page never share control ids. */
|
||||||
|
idPrefix: string;
|
||||||
|
value: LogoSettings;
|
||||||
|
onChange: (patch: Partial<LogoPatch>) => 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<LogoImages | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="voiced-stack">
|
||||||
|
<div className="voiced-muted">Vendor logo (PNG, JPEG or WebP). A transparent PNG works best.</div>
|
||||||
|
|
||||||
|
{value.logoPath ? (
|
||||||
|
<div className="voiced-logo-preview">
|
||||||
|
<figure className="voiced-logo-preview__item">
|
||||||
|
<div className="voiced-logo-preview__surface voiced-logo-preview__surface--light">
|
||||||
|
{images ? <img src={images.printUri} alt="Logo on a white background" /> : <InlineLoading />}
|
||||||
|
</div>
|
||||||
|
<figcaption className="voiced-muted">On white</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure className="voiced-logo-preview__item">
|
||||||
|
<div className="voiced-logo-preview__surface voiced-logo-preview__surface--dark">
|
||||||
|
{images ? (
|
||||||
|
images.knockoutUri ? (
|
||||||
|
<img src={images.knockoutUri} alt="White logo on a dark background" />
|
||||||
|
) : (
|
||||||
|
<span className="voiced-logo-preview__chip">
|
||||||
|
<img src={images.printUri} alt="Logo on a white chip over a dark background" />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<InlineLoading />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<figcaption className="voiced-muted">
|
||||||
|
{images && !images.knockoutUri ? "On dark (white chip)" : "On dark"}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="voiced-muted">{busy ? <InlineLoading description="Importing" /> : "No logo selected"}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meta ? <div className="voiced-muted">{logoFacts(meta)}</div> : null}
|
||||||
|
|
||||||
|
{meta?.opaqueBackground ? (
|
||||||
|
<InlineNotification
|
||||||
|
kind="warning"
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title="Solid background"
|
||||||
|
subtitle="This logo has a solid background. A transparent PNG gives better results on coloured templates."
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="voiced-header-action">
|
||||||
|
<Button kind="tertiary" size="sm" renderIcon={Upload} onClick={pick} disabled={disabled || busy}>
|
||||||
|
{value.logoPath ? "Replace logo" : "Choose image"}
|
||||||
|
</Button>
|
||||||
|
{value.logoPath ? (
|
||||||
|
<Button kind="ghost" size="sm" renderIcon={TrashCan} onClick={remove} disabled={disabled || busy}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{busy && value.logoPath ? <InlineLoading description="Importing" /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <InlineNotification kind="error" lowContrast hideCloseButton title="Could not use that logo" subtitle={error} /> : null}
|
||||||
|
|
||||||
|
{value.logoPath ? (
|
||||||
|
<RadioButtonGroup
|
||||||
|
legendText="Logo already includes the business name"
|
||||||
|
name={`${idPrefix}-logoIncludesName`}
|
||||||
|
orientation="horizontal"
|
||||||
|
valueSelected={value.logoIncludesName}
|
||||||
|
helperText={`Automatic treats ${automaticYes ? "this logo as including the name" : "this logo as a mark without the name"}.`}
|
||||||
|
onChange={(next) => onChange({ logoIncludesName: next as LogoIncludesName })}
|
||||||
|
>
|
||||||
|
<RadioButton id={`${idPrefix}-lin-auto`} labelText="Automatic" value="auto" />
|
||||||
|
<RadioButton id={`${idPrefix}-lin-yes`} labelText="Yes" value="yes" />
|
||||||
|
<RadioButton id={`${idPrefix}-lin-no`} labelText="No" value="no" />
|
||||||
|
</RadioButtonGroup>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
InvoiceSummary,
|
InvoiceSummary,
|
||||||
Settings,
|
Settings,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import type { LogoAsset } from "./logo";
|
||||||
|
|
||||||
export interface ArchiveStatus {
|
export interface ArchiveStatus {
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
@@ -52,6 +53,11 @@ export const api = {
|
|||||||
invoke<string>("import_asset", { sourcePath, kind }),
|
invoke<string>("import_asset", { sourcePath, kind }),
|
||||||
saveAssetBytes: (kind: string, fileName: string, dataBase64: string) =>
|
saveAssetBytes: (kind: string, fileName: string, dataBase64: string) =>
|
||||||
invoke<string>("save_asset_bytes", { kind, fileName, dataBase64 }),
|
invoke<string>("save_asset_bytes", { kind, fileName, dataBase64 }),
|
||||||
|
importLogo: (sourcePath: string) => invoke<LogoAsset>("import_logo", { sourcePath }),
|
||||||
|
saveLogoBytes: (fileName: string, dataBase64: string) =>
|
||||||
|
invoke<LogoAsset>("save_logo_bytes", { fileName, dataBase64 }),
|
||||||
|
ensureLogoDerived: () => invoke<Settings>("ensure_logo_derived"),
|
||||||
|
removeLogo: () => invoke<Settings>("remove_logo"),
|
||||||
readAssetDataUri: (path: string) => invoke<string>("read_asset_data_uri", { path }),
|
readAssetDataUri: (path: string) => invoke<string>("read_asset_data_uri", { path }),
|
||||||
removeAsset: (path: string) => invoke<void>("remove_asset", { path }),
|
removeAsset: (path: string) => invoke<void>("remove_asset", { path }),
|
||||||
|
|
||||||
|
|||||||
@@ -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<Settings> = {}) =>
|
||||||
|
({ 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
+128
@@ -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<Settings, "logoPath" | "logoKnockoutPath" | "logoMeta" | "logoIncludesName">;
|
||||||
|
|
||||||
|
/** 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<LogoMeta> | 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<Settings, "logoPath" | "logoKnockoutPath">,
|
||||||
|
read: (path: string) => Promise<string>,
|
||||||
|
): Promise<LogoImages | null> {
|
||||||
|
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(" • ");
|
||||||
|
}
|
||||||
+5
-8
@@ -5,13 +5,10 @@ import { vendorFromSnapshot } from "../pdf/model/vendor";
|
|||||||
import { Superseded } from "../pdf/render/client";
|
import { Superseded } from "../pdf/render/client";
|
||||||
import type { RenderResult } from "../pdf/render/jobs";
|
import type { RenderResult } from "../pdf/render/jobs";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import { imageSizeFromDataUri } from "./imageSize";
|
import { loadLogoImages, logoForModel, type LogoImages } from "./logo";
|
||||||
import { getRenderClient, nextRenderJobId } from "./renderClient";
|
import { getRenderClient, nextRenderJobId } from "./renderClient";
|
||||||
import type { BankAccount, Invoice, Settings } from "./types";
|
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).
|
* 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.
|
* 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,
|
invoice: Invoice,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
bank: BankAccount | null,
|
bank: BankAccount | null,
|
||||||
logoUri: string | null,
|
logoImages: LogoImages | null,
|
||||||
signatureDataUri: string | null,
|
signatureDataUri: string | null,
|
||||||
): RenderModel {
|
): RenderModel {
|
||||||
return buildRenderModel({
|
return buildRenderModel({
|
||||||
invoice,
|
invoice,
|
||||||
vendor: settings,
|
vendor: settings,
|
||||||
bank,
|
bank,
|
||||||
logo: logoUri ? { dataUri: logoUri, ...(imageSizeFromDataUri(logoUri) ?? UNKNOWN_LOGO_SIZE) } : null,
|
logo: logoForModel(vendorFromSnapshot(invoice.vendorSnapshot, settings), logoImages),
|
||||||
signatureDataUri,
|
signatureDataUri,
|
||||||
appVersion: pkg.version,
|
appVersion: pkg.version,
|
||||||
});
|
});
|
||||||
@@ -36,9 +33,9 @@ export function modelFromAssets(
|
|||||||
/** Loads the invoice's logo and signature, then builds its render model. */
|
/** Loads the invoice's logo and signature, then builds its render model. */
|
||||||
export async function buildModelForInvoice(invoice: Invoice, settings: Settings, bank: BankAccount | null): Promise<RenderModel> {
|
export async function buildModelForInvoice(invoice: Invoice, settings: Settings, bank: BankAccount | null): Promise<RenderModel> {
|
||||||
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
|
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;
|
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. */
|
/** Page setup for an invoice: what was frozen at issue, else the user's current default. */
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export function vendorSnapshotJson(settings: Settings): string {
|
|||||||
vendorGstin: settings.gstRegistration === "unregistered" ? "" : settings.vendorGstin.trim(),
|
vendorGstin: settings.gstRegistration === "unregistered" ? "" : settings.vendorGstin.trim(),
|
||||||
vendorStateCode: settings.vendorStateCode,
|
vendorStateCode: settings.vendorStateCode,
|
||||||
logoPath: settings.logoPath,
|
logoPath: settings.logoPath,
|
||||||
|
logoKnockoutPath: settings.logoKnockoutPath,
|
||||||
|
logoMeta: settings.logoMeta,
|
||||||
|
logoIncludesName: settings.logoIncludesName,
|
||||||
signaturePath: settings.signaturePath,
|
signaturePath: settings.signaturePath,
|
||||||
gstRegistration: settings.gstRegistration,
|
gstRegistration: settings.gstRegistration,
|
||||||
signatoryName: settings.signatoryName,
|
signatoryName: settings.signatoryName,
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ export interface Settings {
|
|||||||
renderPrefs: string;
|
renderPrefs: string;
|
||||||
/** Folder of the last export; kept by the backend (saving settings does not change it). */
|
/** Folder of the last export; kept by the backend (saving settings does not change it). */
|
||||||
lastExportDir?: string;
|
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 {
|
export interface BankAccount {
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ const vendor: Settings = {
|
|||||||
vendorGstin: "",
|
vendorGstin: "",
|
||||||
vendorStateCode: "29",
|
vendorStateCode: "29",
|
||||||
logoPath: null,
|
logoPath: null,
|
||||||
|
logoOriginalPath: null,
|
||||||
|
logoKnockoutPath: null,
|
||||||
|
logoMeta: "",
|
||||||
|
logoIncludesName: "auto",
|
||||||
signaturePath: null,
|
signaturePath: null,
|
||||||
defaultBankId: 1,
|
defaultBankId: 1,
|
||||||
defaultTaxRate: 18,
|
defaultTaxRate: 18,
|
||||||
@@ -229,7 +233,17 @@ describe("cancelled and logo", () => {
|
|||||||
expect(make({ status: "draft" }).cancelled).toBe(false);
|
expect(make({ status: "draft" }).cancelled).toBe(false);
|
||||||
});
|
});
|
||||||
it("passes the logo and signature through, and keeps null when absent", () => {
|
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==" });
|
const m = make({}, {}, { logo, signatureDataUri: "data:image/png;base64,BB==" });
|
||||||
expect(m.logo).toEqual(logo);
|
expect(m.logo).toEqual(logo);
|
||||||
expect(m.signature.dataUri).toBe("data:image/png;base64,BB==");
|
expect(m.signature.dataUri).toBe("data:image/png;base64,BB==");
|
||||||
|
|||||||
@@ -11,9 +11,21 @@ import { sanitize, splitLines } from "./text";
|
|||||||
import { bankFromSnapshot, vendorFromSnapshot } from "./vendor";
|
import { bankFromSnapshot, vendorFromSnapshot } from "./vendor";
|
||||||
|
|
||||||
export interface LogoInput {
|
export interface LogoInput {
|
||||||
|
/** The trimmed print image. */
|
||||||
dataUri: string;
|
dataUri: string;
|
||||||
|
/** Pixel size of `dataUri`. */
|
||||||
width: number;
|
width: number;
|
||||||
height: 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 {
|
export interface BuildInput {
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ import type { BankAccount, Settings } from "../../lib/types";
|
|||||||
export function vendorFromSnapshot(snapshot: string | undefined, live: Settings): Settings {
|
export function vendorFromSnapshot(snapshot: string | undefined, live: Settings): Settings {
|
||||||
if (!snapshot) return live;
|
if (!snapshot) return live;
|
||||||
try {
|
try {
|
||||||
return { ...live, ...(JSON.parse(snapshot) as Partial<Settings>) };
|
const frozen = JSON.parse(snapshot) as Partial<Settings>;
|
||||||
|
// 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 {
|
} catch {
|
||||||
return live;
|
return live;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ const baseVendor: Settings = {
|
|||||||
vendorGstin: "",
|
vendorGstin: "",
|
||||||
vendorStateCode: "29",
|
vendorStateCode: "29",
|
||||||
logoPath: null,
|
logoPath: null,
|
||||||
|
logoOriginalPath: null,
|
||||||
|
logoKnockoutPath: null,
|
||||||
|
logoMeta: "",
|
||||||
|
logoIncludesName: "auto",
|
||||||
signaturePath: null,
|
signaturePath: null,
|
||||||
defaultBankId: 1,
|
defaultBankId: 1,
|
||||||
defaultTaxRate: 18,
|
defaultTaxRate: 18,
|
||||||
@@ -136,12 +140,13 @@ export function fixtureLogo(kind: LogoKind): LogoInput | null {
|
|||||||
if (kind === "real") {
|
if (kind === "real") {
|
||||||
// 2172 x 724: the untrimmed Arun P logo, a 3:1 aspect.
|
// 2172 x 724: the untrimmed Arun P logo, a 3:1 aspect.
|
||||||
const png = readFileSync(path.resolve(process.cwd(), "public/brand/arun-p-logo.png"));
|
const png = readFileSync(path.resolve(process.cwd(), "public/brand/arun-p-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") {
|
} else if (kind === "wide") {
|
||||||
// The trimmed Arun P ink box is 4.6:1.
|
// The trimmed Arun P 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 {
|
} 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);
|
cache.set(kind, hit);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ const VENDOR: Settings = {
|
|||||||
vendorGstin: "",
|
vendorGstin: "",
|
||||||
vendorStateCode: "29",
|
vendorStateCode: "29",
|
||||||
logoPath: null,
|
logoPath: null,
|
||||||
|
logoOriginalPath: null,
|
||||||
|
logoKnockoutPath: null,
|
||||||
|
logoMeta: "",
|
||||||
|
logoIncludesName: "auto",
|
||||||
signaturePath: null,
|
signaturePath: null,
|
||||||
defaultBankId: 1,
|
defaultBankId: 1,
|
||||||
defaultTaxRate: 18,
|
defaultTaxRate: 18,
|
||||||
|
|||||||
@@ -396,3 +396,59 @@ body {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TextArea,
|
TextArea,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
Tile,
|
||||||
Toggle,
|
Toggle,
|
||||||
} from "@carbon/react";
|
} from "@carbon/react";
|
||||||
import { Add, Save, TrashCan } from "@carbon/icons-react";
|
import { Add, Save, TrashCan } from "@carbon/icons-react";
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
} from "../lib/types";
|
} from "../lib/types";
|
||||||
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
||||||
import { ImagePicker } from "../components/ImagePicker";
|
import { ImagePicker } from "../components/ImagePicker";
|
||||||
|
import { LogoBranding } from "../components/LogoBranding";
|
||||||
import DiagnosticsPanel from "../components/DiagnosticsPanel";
|
import DiagnosticsPanel from "../components/DiagnosticsPanel";
|
||||||
|
|
||||||
const blankBank = (): BankAccount => ({
|
const blankBank = (): BankAccount => ({
|
||||||
@@ -264,14 +266,6 @@ export default function AppSettings({
|
|||||||
onChange={(e) => set("signatoryDesignation", e.target.value)}
|
onChange={(e) => set("signatoryDesignation", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
<Column sm={4} md={8} lg={8}>
|
|
||||||
<ImagePicker
|
|
||||||
kind="logo"
|
|
||||||
label="Vendor logo"
|
|
||||||
value={form.logoPath}
|
|
||||||
onChange={(path) => set("logoPath", path)}
|
|
||||||
/>
|
|
||||||
</Column>
|
|
||||||
<Column sm={4} md={8} lg={8}>
|
<Column sm={4} md={8} lg={8}>
|
||||||
<ImagePicker
|
<ImagePicker
|
||||||
kind="signature"
|
kind="signature"
|
||||||
@@ -283,6 +277,18 @@ export default function AppSettings({
|
|||||||
</Grid>
|
</Grid>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||||
|
<h3>Branding</h3>
|
||||||
|
<Tile>
|
||||||
|
<LogoBranding
|
||||||
|
idPrefix="s"
|
||||||
|
value={form}
|
||||||
|
onChange={(patch) => setForm((prev) => ({ ...prev, ...patch }))}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</Tile>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||||
<h3>Defaults</h3>
|
<h3>Defaults</h3>
|
||||||
<Grid narrow>
|
<Grid narrow>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import { loadExportMode } from "../lib/exportMode";
|
|||||||
import type { ExportMode } from "../lib/filename";
|
import type { ExportMode } from "../lib/filename";
|
||||||
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
|
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||||
import { ImagePicker } from "../components/ImagePicker";
|
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.
|
// pdf.js is only needed once a preview exists, so it loads on demand.
|
||||||
const PdfPreview = lazy(() => import("../components/PdfPreview"));
|
const PdfPreview = lazy(() => import("../components/PdfPreview"));
|
||||||
@@ -135,7 +136,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
const [signaturePath, setSignaturePath] = useState<string | null>(settings.signaturePath);
|
const [signaturePath, setSignaturePath] = useState<string | null>(settings.signaturePath);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
const [logoUri, setLogoUri] = useState<string | null>(null);
|
const [logoImages, setLogoImages] = useState<LogoImages | null>(null);
|
||||||
const [signatureUri, setSignatureUri] = useState<string | null>(null);
|
const [signatureUri, setSignatureUri] = useState<string | null>(null);
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -196,14 +197,16 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
if (!settings.logoPath) {
|
if (!settings.logoPath) {
|
||||||
setLogoUri(null);
|
setLogoImages(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api.readAssetDataUri(settings.logoPath).then((uri) => active && setLogoUri(uri)).catch(() => {});
|
loadLogoImages(settings, api.readAssetDataUri)
|
||||||
|
.then((images) => active && setLogoImages(images))
|
||||||
|
.catch(() => {});
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [settings.logoPath]);
|
}, [settings.logoPath, settings.logoKnockoutPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -365,8 +368,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
settings,
|
settings,
|
||||||
bank,
|
bank,
|
||||||
);
|
);
|
||||||
return modelFromAssets(invoice, 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, logoUri, 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<InvoiceItem>) =>
|
const updateItem = (index: number, patch: Partial<InvoiceItem>) =>
|
||||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { BankAccount, GstRegistration, Settings, TaxType } from "../lib/typ
|
|||||||
import { INDIAN_STATES } from "../lib/types";
|
import { INDIAN_STATES } from "../lib/types";
|
||||||
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
||||||
import { ImagePicker } from "../components/ImagePicker";
|
import { ImagePicker } from "../components/ImagePicker";
|
||||||
|
import { LogoBranding } from "../components/LogoBranding";
|
||||||
|
|
||||||
interface OnboardingProps {
|
interface OnboardingProps {
|
||||||
initial: Settings;
|
initial: Settings;
|
||||||
@@ -219,11 +220,11 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
|
|||||||
content: (
|
content: (
|
||||||
<Grid narrow>
|
<Grid narrow>
|
||||||
<Column sm={4} md={8} lg={12}>
|
<Column sm={4} md={8} lg={12}>
|
||||||
<ImagePicker
|
<LogoBranding
|
||||||
kind="logo"
|
idPrefix="o"
|
||||||
label="Vendor logo (shown on the invoice header)"
|
value={form}
|
||||||
value={form.logoPath}
|
onChange={(patch) => setForm((prev) => ({ ...prev, ...patch }))}
|
||||||
onChange={(path) => set("logoPath", path)}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
<Column sm={4} md={8} lg={12}>
|
<Column sm={4} md={8} lg={12}>
|
||||||
|
|||||||
Reference in New Issue
Block a user