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