Add Rust logo pipeline: trim, metrics, knockout and Branding settings

Logos (PNG, JPEG, WebP) are decoded, trimmed to their ink box, downscaled
to 1200 px and re-encoded as a content-addressed print PNG plus a white
knockout variant when the logo has alpha; aspect, ink density, mean colour
and kind (wordmark, mark, tall) are stored with the settings and frozen in
the issue snapshot. Legacy logos are derived at startup. The Arun P logo
PDF shrinks from 334 KB to 92 KB. Settings gains a Branding section with
white and dark previews and a business-name toggle (migration M5).
This commit is contained in:
2026-10-04 06:31:30 +05:30
parent afd07071c3
commit 74ba04e17f
27 changed files with 1615 additions and 37 deletions
+47 -2
View File
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
const BACKUP_PREFIX: &str = "voiced-pre-v";
/// 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)]
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 '';
"#;
/// 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> {
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4)])
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5)])
}
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
@@ -585,6 +594,42 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
assert_eq!(stored, json);
}
#[test]
fn logo_columns_default_and_existing_logo_is_untouched() {
let conn = open_in_memory().unwrap();
let row: (Option<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) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(V1_SCHEMA).unwrap();