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).
129 lines
4.8 KiB
TypeScript
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(" • ");
|
|
}
|