Files
Voiced/src/pdf/engine/logo.ts
T
xavierk aa7a407944 Add the Neutral template family, template picker and text-only logo allowances
Linea (the vendor's original format.pdf design), Monogram and Serenity are
rebuilt from the reference PDFs as one parametric family on the engine, with
the user's logo in the measured slot, a home for every GST field and
A4/Letter support. Adds static Montserrat, Open Sans and Poppins fonts and
a generated Ms Madi outline for Serenity's vertical word. Text-only logo
placeholders (Monolith, Tangerine) use a doc-derived allowance box. A
Carbon TemplatePicker with committed thumbnails opens from the page
setup controls.
2026-10-04 07:27:12 +05:30

145 lines
5.7 KiB
TypeScript

import type { Box, TemplateSlot } from "../templates/slots.types";
export interface LogoPlacementInput {
/** Width / height of the image (use the trimmed ink aspect once the logo pipeline provides it). */
aspect: number;
/** The largest box the logo may fill, pt. */
box: { w: number; h: number };
/** Width of the column the logo and the text beside it share. */
availableWidth: number;
/** Narrowest the text may get beside the logo before the logo moves above it. */
minTextWidth: number;
/** Space between logo and text when side by side. Default 14. */
gap?: number;
}
export interface LogoPlacement {
w: number;
h: number;
/** True when the logo goes above the text instead of beside it. */
stacked: boolean;
}
export const LOGO_GAP = 14;
/**
* Fits the logo inside `box` by its aspect (contain), then decides whether it still leaves the text
* `minTextWidth` beside it. Same maths as the Phase 0 planLogo.
*/
export function computeClassicLogoPlacement({ aspect, box, availableWidth, minTextWidth, gap = LOGO_GAP }: LogoPlacementInput): LogoPlacement {
const a = Number.isFinite(aspect) && aspect > 0 ? aspect : box.w / box.h;
const h = Math.min(box.h, box.w / a);
const w = h * a;
return { w, h, stacked: availableWidth - w - gap < minTextWidth };
}
/** Smallest height a logo is drawn at when its box allows it, pt. */
export const MIN_LOGO_HEIGHT = 14;
/** White padding of the chip behind a logo that has no knockout variant, pt. */
export const CHIP_PADDING = 4;
/** The weight factor never shrinks a logo below this share of its contain-fit size. */
const MIN_WEIGHT_FACTOR = 0.7;
/** Ratio of the page's content width to the reference page's (1 for the page the slot was measured on). */
export type PageScale = number;
export interface SlotLogoInput {
/** Width / height of the logo's ink box. */
aspect: number;
/** Share of the ink box that is ink, 0 to 1. */
density: number;
kind: "wordmark" | "mark" | "tall";
/** The logo already carries the business name, so it replaces the template's whole lockup. */
includesName: boolean;
/** A white-ink variant exists, for dark surfaces. */
hasKnockout: boolean;
}
export interface SlotLogoPlacementInput {
logo: SlotLogoInput;
slot: TemplateSlot;
scale?: PageScale;
/** Room the layout can actually give the logo; the target box shrinks to it, keeping its anchor. */
available?: { w: number; h: number };
}
export interface SlotLogoPlacement {
/** Where the logo image goes, page points from the top-left. */
box: Box;
x: number;
y: number;
w: number;
h: number;
variant: "print" | "knockout" | "chip";
/** Which placeholder box the logo was fitted to. */
slotKind: "lockup" | "mark";
/** For the chip variant: the white rounded rectangle behind the logo (the logo box plus CHIP_PADDING). */
chip?: Box;
/** Only Classic's own stack-or-beside decision sets this; slot placement never stacks. */
stacked?: boolean;
}
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** A w x h box inside `target`, aligned the way the placeholder was. */
function align(target: Box, w: number, h: number, slot: TemplateSlot): Box {
const x = slot.anchor === "left" ? target.x : slot.anchor === "right" ? target.x + target.w - w : target.x + (target.w - w) / 2;
const y = slot.vAlign === "top" ? target.y : slot.vAlign === "bottom" ? target.y + target.h - h : target.y + (target.h - h) / 2;
return { x, y, w, h };
}
/**
* Places the user's logo where a template's logo placeholder is. Contain-fit in the measured box, then
* a shrink-only weight factor so a denser logo draws smaller and matches the placeholder's visual
* weight. A wordmark (includesName) replaces the whole lockup; a mark replaces the icon only. A
* text-only placeholder (Monolith's "CAVEIN", Tangerine's "Rimberio") fits the template's allowance
* instead, with the weight factor still computed against the text's density. The legibility floor never pushes past the box: a box under MIN_LOGO_HEIGHT stays the limit.
*/
export function computeLogoPlacement({ logo, slot, scale = 1, available }: SlotLogoPlacementInput): SlotLogoPlacement {
const wantLockup = logo.includesName || !slot.mark;
// A text-only placeholder is too small for a logo; its allowance (from the template docs) is the target.
const target = slot.textOnly ? slot.allowance : wantLockup ? (slot.lockup ?? slot.mark) : slot.mark;
if (!target) throw new Error("slot has neither a mark nor a lockup box");
const slotKind = wantLockup ? "lockup" : "mark";
const placeholderDensity = wantLockup ? slot.lockupDensity : slot.markDensity;
// Scale the box about its anchor so a right-aligned slot keeps its right edge.
let bw = target.w * scale;
let bh = target.h * scale;
if (available) {
bw = Math.min(bw, available.w);
bh = Math.min(bh, available.h);
}
const box = align(target, bw, bh, slot);
const aspect = Number.isFinite(logo.aspect) && logo.aspect > 0 ? logo.aspect : bw / bh;
let h = Math.min(bh, bw / aspect);
let w = h * aspect;
const valid = logo.density > 0 && placeholderDensity > 0;
const f = valid ? clamp(Math.sqrt(placeholderDensity / logo.density), MIN_WEIGHT_FACTOR, 1) : 1;
w *= f;
h *= f;
if (h < MIN_LOGO_HEIGHT) {
h = MIN_LOGO_HEIGHT;
w = h * aspect;
if (w > bw) {
w = bw;
h = w / aspect;
}
if (h > bh) {
h = bh;
w = h * aspect;
}
}
const placed = align(box, w, h, slot);
const variant = slot.darkSurface ? (logo.hasKnockout ? "knockout" : "chip") : "print";
const out: SlotLogoPlacement = { box: placed, ...placed, variant, slotKind };
if (variant === "chip") {
out.chip = { x: placed.x - CHIP_PADDING, y: placed.y - CHIP_PADDING, w: placed.w + 2 * CHIP_PADDING, h: placed.h + 2 * CHIP_PADDING };
}
return out;
}