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 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).
This commit is contained in:
2026-10-04 06:31:30 +05:30
parent 3c5fd617cd
commit e1edf02448
27 changed files with 1615 additions and 37 deletions
+128
View File
@@ -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(" • ");
}