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
+6
View File
@@ -9,6 +9,7 @@ import type {
InvoiceSummary,
Settings,
} from "./types";
import type { LogoAsset } from "./logo";
export interface ArchiveStatus {
archived: boolean;
@@ -52,6 +53,11 @@ export const api = {
invoke<string>("import_asset", { sourcePath, kind }),
saveAssetBytes: (kind: string, fileName: string, dataBase64: string) =>
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 }),
removeAsset: (path: string) => invoke<void>("remove_asset", { path }),
+121
View File
@@ -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
View File
@@ -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
View File
@@ -5,13 +5,10 @@ import { vendorFromSnapshot } from "../pdf/model/vendor";
import { Superseded } from "../pdf/render/client";
import type { RenderResult } from "../pdf/render/jobs";
import { api } from "./api";
import { imageSizeFromDataUri } from "./imageSize";
import { loadLogoImages, logoForModel, type LogoImages } from "./logo";
import { getRenderClient, nextRenderJobId } from "./renderClient";
import type { BankAccount, Invoice, Settings } from "./types";
/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */
const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 };
/**
* The render model for an invoice whose images are already loaded (the live preview caches them).
* Supplier and bank come from the snapshots frozen at issue; live settings are only a fallback.
@@ -20,14 +17,14 @@ export function modelFromAssets(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
logoUri: string | null,
logoImages: LogoImages | null,
signatureDataUri: string | null,
): RenderModel {
return buildRenderModel({
invoice,
vendor: settings,
bank,
logo: logoUri ? { dataUri: logoUri, ...(imageSizeFromDataUri(logoUri) ?? UNKNOWN_LOGO_SIZE) } : null,
logo: logoForModel(vendorFromSnapshot(invoice.vendorSnapshot, settings), logoImages),
signatureDataUri,
appVersion: pkg.version,
});
@@ -36,9 +33,9 @@ export function modelFromAssets(
/** Loads the invoice's logo and signature, then builds its render model. */
export async function buildModelForInvoice(invoice: Invoice, settings: Settings, bank: BankAccount | null): Promise<RenderModel> {
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
const logoUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
const logoImages = await loadLogoImages(vendor, api.readAssetDataUri);
const signatureDataUri = invoice.signaturePath ? await api.readAssetDataUri(invoice.signaturePath) : null;
return modelFromAssets(invoice, settings, bank, logoUri, signatureDataUri);
return modelFromAssets(invoice, settings, bank, logoImages, signatureDataUri);
}
/** Page setup for an invoice: what was frozen at issue, else the user's current default. */
+3
View File
@@ -40,6 +40,9 @@ export function vendorSnapshotJson(settings: Settings): string {
vendorGstin: settings.gstRegistration === "unregistered" ? "" : settings.vendorGstin.trim(),
vendorStateCode: settings.vendorStateCode,
logoPath: settings.logoPath,
logoKnockoutPath: settings.logoKnockoutPath,
logoMeta: settings.logoMeta,
logoIncludesName: settings.logoIncludesName,
signaturePath: settings.signaturePath,
gstRegistration: settings.gstRegistration,
signatoryName: settings.signatoryName,
+8
View File
@@ -27,6 +27,14 @@ export interface Settings {
renderPrefs: string;
/** Folder of the last export; kept by the backend (saving settings does not change it). */
lastExportDir?: string;
/** The untouched logo file the derived images came from. */
logoOriginalPath: string | null;
/** White-ink variant for dark surfaces; only for logos with transparency. */
logoKnockoutPath: string | null;
/** LogoMeta JSON from the import pipeline; empty until the logo has been through it. */
logoMeta: string;
/** Whether the logo already contains the business name. Automatic means a wordmark does. */
logoIncludesName: "auto" | "yes" | "no";
}
export interface BankAccount {