Add Rust logo pipeline: trim, metrics, knockout and Branding settings
Logos (PNG, JPEG, WebP) are decoded, trimmed to their ink box, downscaled to 1200 px and re-encoded as a content-addressed print PNG plus a white knockout variant when the logo has alpha; aspect, ink density, mean colour and kind (wordmark, mark, tall) are stored with the settings and frozen in the issue snapshot. Legacy logos are derived at startup. The Test Vendor logo PDF shrinks from 334 KB to 92 KB. Settings gains a Branding section with white and dark previews and a business-name toggle (migration M5).
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 Test Vendor logo drops from 149 KB to 75 KB.
|
||||||
|
fn quantize_colour(img: &mut RgbaImage) {
|
||||||
|
for p in img.pixels_mut() {
|
||||||
|
for c in 0..3 {
|
||||||
|
p.0[c] = ((((p.0[c] as u32 * 63 + 127) / 255) * 255 + 31) / 63) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_png(img: &DynamicImage) -> Result<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 sample_logo_is_trimmed_and_small() {
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/brand/sample-wordmark-logo.png");
|
||||||
|
let bytes = std::fs::read(path).unwrap();
|
||||||
|
let out = process_logo(&bytes).unwrap();
|
||||||
|
let m = &out.metrics;
|
||||||
|
assert_eq!((m.source_width, m.source_height), (2172, 724));
|
||||||
|
assert!((4.5..=4.7).contains(&m.aspect), "aspect {}", m.aspect);
|
||||||
|
assert!((0.40..=0.46).contains(&m.ink_density), "density {}", m.ink_density);
|
||||||
|
assert_eq!(m.kind, LogoKind::Wordmark);
|
||||||
|
assert!(out.print_png.len() <= 120 * 1024, "print is {} bytes", out.print_png.len());
|
||||||
|
assert!(out.knockout_png.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 Test Vendor logo, a 3:1 aspect.
|
// 2172 x 724: the untrimmed Test Vendor logo, a 3:1 aspect.
|
||||||
const png = readFileSync(path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png"));
|
const png = readFileSync(path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png"));
|
||||||
hit = { dataUri: dataUri(png), width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
const [width, height] = [png.readUInt32BE(16), png.readUInt32BE(20)];
|
||||||
|
hit = { dataUri: dataUri(png), width, height, aspect: width / height, density: 0.21, kind: "wordmark", includesName: true };
|
||||||
} else if (kind === "wide") {
|
} else if (kind === "wide") {
|
||||||
// The trimmed Test Vendor ink box is 4.6:1.
|
// The trimmed Test Vendor ink box is 4.6:1.
|
||||||
hit = { dataUri: dataUri(solidPng(460, 100)), width: 460, height: 100 };
|
hit = { dataUri: dataUri(solidPng(460, 100)), width: 460, height: 100, aspect: 4.6, density: 1, kind: "wordmark", includesName: true };
|
||||||
} else {
|
} 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