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:
@@ -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,
|
||||
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 }),
|
||||
|
||||
|
||||
@@ -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 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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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==");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 Arun P logo, a 3:1 aspect.
|
||||
const png = readFileSync(path.resolve(process.cwd(), "public/brand/arun-p-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 Arun P 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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)));
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user