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:
+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(" • ");
|
||||
}
|
||||
Reference in New Issue
Block a user