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:
2026-10-04 06:31:30 +05:30
parent 3c5fd617cd
commit e1edf02448
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();