Files
Voiced/src/lib/logo.ts
T
xavierk 74ba04e17f 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).
2026-10-04 06:31:30 +05:30

129 lines
4.8 KiB
TypeScript

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(" • ");
}