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
+165
View File
@@ -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>
);
}
+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 {
+15 -1
View File
@@ -12,6 +12,10 @@ const vendor: Settings = {
vendorGstin: "",
vendorStateCode: "29",
logoPath: null,
logoOriginalPath: null,
logoKnockoutPath: null,
logoMeta: "",
logoIncludesName: "auto",
signaturePath: null,
defaultBankId: 1,
defaultTaxRate: 18,
@@ -229,7 +233,17 @@ describe("cancelled and logo", () => {
expect(make({ status: "draft" }).cancelled).toBe(false);
});
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==" });
expect(m.logo).toEqual(logo);
expect(m.signature.dataUri).toBe("data:image/png;base64,BB==");
+12
View File
@@ -11,9 +11,21 @@ import { sanitize, splitLines } from "./text";
import { bankFromSnapshot, vendorFromSnapshot } from "./vendor";
export interface LogoInput {
/** The trimmed print image. */
dataUri: string;
/** Pixel size of `dataUri`. */
width: 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 {
+9 -1
View File
@@ -7,7 +7,15 @@ import type { BankAccount, Settings } from "../../lib/types";
export function vendorFromSnapshot(snapshot: string | undefined, live: Settings): Settings {
if (!snapshot) return live;
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 {
return live;
}
+8 -3
View File
@@ -38,6 +38,10 @@ const baseVendor: Settings = {
vendorGstin: "",
vendorStateCode: "29",
logoPath: null,
logoOriginalPath: null,
logoKnockoutPath: null,
logoMeta: "",
logoIncludesName: "auto",
signaturePath: null,
defaultBankId: 1,
defaultTaxRate: 18,
@@ -136,12 +140,13 @@ export function fixtureLogo(kind: LogoKind): LogoInput | null {
if (kind === "real") {
// 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"));
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") {
// 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 {
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);
}
+4
View File
@@ -28,6 +28,10 @@ const VENDOR: Settings = {
vendorGstin: "",
vendorStateCode: "29",
logoPath: null,
logoOriginalPath: null,
logoKnockoutPath: null,
logoMeta: "",
logoIncludesName: "auto",
signaturePath: null,
defaultBankId: 1,
defaultTaxRate: 18,
+56
View File
@@ -396,3 +396,59 @@ body {
white-space: pre-wrap;
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;
}
+14 -8
View File
@@ -19,6 +19,7 @@ import {
TableRow,
TextArea,
TextInput,
Tile,
Toggle,
} from "@carbon/react";
import { Add, Save, TrashCan } from "@carbon/icons-react";
@@ -33,6 +34,7 @@ import {
} from "../lib/types";
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker";
import { LogoBranding } from "../components/LogoBranding";
import DiagnosticsPanel from "../components/DiagnosticsPanel";
const blankBank = (): BankAccount => ({
@@ -264,14 +266,6 @@ export default function AppSettings({
onChange={(e) => set("signatoryDesignation", e.target.value)}
/>
</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}>
<ImagePicker
kind="signature"
@@ -283,6 +277,18 @@ export default function AppSettings({
</Grid>
</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" }}>
<h3>Defaults</h3>
<Grid narrow>
+9 -6
View File
@@ -65,6 +65,7 @@ import { loadExportMode } from "../lib/exportMode";
import type { ExportMode } from "../lib/filename";
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
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.
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 [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 [saving, setSaving] = useState(false);
@@ -196,14 +197,16 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
useEffect(() => {
let active = true;
if (!settings.logoPath) {
setLogoUri(null);
setLogoImages(null);
return;
}
api.readAssetDataUri(settings.logoPath).then((uri) => active && setLogoUri(uri)).catch(() => {});
loadLogoImages(settings, api.readAssetDataUri)
.then((images) => active && setLogoImages(images))
.catch(() => {});
return () => {
active = false;
};
}, [settings.logoPath]);
}, [settings.logoPath, settings.logoKnockoutPath]);
useEffect(() => {
let active = true;
@@ -365,8 +368,8 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
settings,
bank,
);
return modelFromAssets(invoice, settings, bank, logoUri, signatureUri);
}, [active, issued, invoiceDate, dueDate, clientId, clientName, clientAddress, clientGstin, poNumber, placeOfSupply, discount, taxRate, reverseCharge, signaturePath, notes, items, number, 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, logoImages, signatureUri]);
const updateItem = (index: number, patch: Partial<InvoiceItem>) =>
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
+6 -5
View File
@@ -19,6 +19,7 @@ import type { BankAccount, GstRegistration, Settings, TaxType } from "../lib/typ
import { INDIAN_STATES } from "../lib/types";
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker";
import { LogoBranding } from "../components/LogoBranding";
interface OnboardingProps {
initial: Settings;
@@ -219,11 +220,11 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
content: (
<Grid narrow>
<Column sm={4} md={8} lg={12}>
<ImagePicker
kind="logo"
label="Vendor logo (shown on the invoice header)"
value={form.logoPath}
onChange={(path) => set("logoPath", path)}
<LogoBranding
idPrefix="o"
value={form}
onChange={(patch) => setForm((prev) => ({ ...prev, ...patch }))}
disabled={saving}
/>
</Column>
<Column sm={4} md={8} lg={12}>