Measure reference logo placeholders and add slot-based logo placement

scripts/logo/measure-placeholders rasterises the reference PDFs and measures
each logo placeholder's ink box and density into slots.generated.ts. The
new placement contains the logo in the measured box, shrinks denser logos
by up to 30 percent to match visual weight, picks knockout or a white chip
on dark surfaces, and passes an identity test per template. A contact
sheet compares each placeholder with the Test Vendor logo and three test logos.
This commit is contained in:
2026-10-04 06:40:10 +05:30
parent e1edf02448
commit eddf27629f
11 changed files with 984 additions and 21 deletions
+22
View File
@@ -0,0 +1,22 @@
// Generated by `npm run logo:measure` from the reference template PDFs (scripts/logo/measure-placeholders.mjs).
// Do not edit by hand: change the search rectangles in the script and run it again.
// Boxes are the ink boxes of each reference logo placeholder, in points from the top-left of the 595.5 x 842.25 page.
// Templates without a placeholder are null here; their opt-in slots are in slots.optin.ts.
import type { TemplateId, TemplateSlot } from "./slots.types";
export const SLOTS: Record<TemplateId, TemplateSlot | null> = {
"classic": null,
"marble": null,
"highlighter": null,
"monolith": {"page":{"w":595.5,"h":842.25},"mark":null,"lockup":{"x":513.25,"y":54.75,"w":46.75,"h":9},"markDensity":0,"lockupDensity":0.579,"anchor":"right","vAlign":"middle","darkSurface":false},
"linea": null,
"monogram": {"page":{"w":595.5,"h":842.25},"mark":{"x":387.75,"y":81.25,"w":142.75,"h":116.5},"lockup":{"x":387.75,"y":81.25,"w":142.75,"h":116.5},"markDensity":0.142,"lockupDensity":0.142,"anchor":"right","vAlign":"middle","darkSurface":false},
"serenity": {"page":{"w":595.5,"h":842.25},"mark":{"x":444.5,"y":131.75,"w":82,"h":81.75},"lockup":{"x":444.5,"y":131.75,"w":82,"h":81.75},"markDensity":0.069,"lockupDensity":0.069,"anchor":"right","vAlign":"middle","darkSurface":false},
"crimson-grid": {"page":{"w":595.5,"h":842.25},"mark":{"x":420.5,"y":53,"w":31.25,"h":45.75},"lockup":{"x":420.5,"y":53,"w":113.25,"h":45.75},"markDensity":0.466,"lockupDensity":0.261,"anchor":"right","vAlign":"bottom","darkSurface":false},
"cobalt-stripe": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":59.5,"w":34.25,"h":34.25},"lockup":{"x":59.5,"y":59.5,"w":181.5,"h":34.25},"markDensity":0.479,"lockupDensity":0.287,"anchor":"left","vAlign":"middle","darkSurface":false},
"slate-band": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":55.75,"w":25,"h":31.25},"lockup":{"x":59.5,"y":55.75,"w":99.25,"h":31.25},"markDensity":0.492,"lockupDensity":0.181,"anchor":"left","vAlign":"middle","darkSurface":true},
"teal-swoosh": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":59.5,"w":41,"h":27},"lockup":{"x":59.5,"y":59.5,"w":125.75,"h":29.25},"markDensity":0.445,"lockupDensity":0.233,"anchor":"left","vAlign":"middle","darkSurface":false},
"tangerine-ledger": {"page":{"w":595.5,"h":842.25},"mark":null,"lockup":{"x":60.5,"y":69.75,"w":82.5,"h":14.75},"markDensity":0,"lockupDensity":0.486,"anchor":"left","vAlign":"middle","darkSurface":false},
"purple-pop": null,
"citrus-split": {"page":{"w":595.5,"h":842.25},"mark":{"x":337.5,"y":74.5,"w":34,"h":32.75},"lockup":{"x":337.5,"y":73.25,"w":144.5,"h":34},"markDensity":0.543,"lockupDensity":0.259,"anchor":"left","vAlign":"middle","darkSurface":false},
};
+44
View File
@@ -0,0 +1,44 @@
import { SLOT_PAGE, type Box, type TemplateId, type TemplateSlot } from "./slots.types";
import { SLOTS } from "./slots.generated";
/*
* Reference ships no logo placeholder for these four templates, so there is nothing to measure. Each slot
* is a free area named in docs/templates/NN-*.md and is an opt-in position, off by default (the plan
* keeps Linea logo-free so it still matches format.pdf). Hand-written; not produced by logo:measure.
*
* With no placeholder ink to match, the density is a typical logo's (Test Vendor is 0.437), so the weight
* factor in computeLogoPlacement only shrinks logos that are denser than that.
*/
const TYPICAL_DENSITY = 0.4;
function slot(lockup: Box, anchor: TemplateSlot["anchor"], vAlign: TemplateSlot["vAlign"], mark: Box = lockup): TemplateSlot {
return {
page: { ...SLOT_PAGE },
mark,
lockup,
markDensity: TYPICAL_DENSITY,
lockupDensity: TYPICAL_DENSITY,
anchor,
vAlign,
darkSurface: false,
optIn: true,
defaultOn: false,
};
}
export const OPTIN_SLOTS: Partial<Record<TemplateId, TemplateSlot>> = {
// 01-marble: header-left is empty (x 38.5-300), bottom-aligned with the title baseline; 150 x 50, bottom edge y 136.
marble: slot({ x: 38.5, y: 86, w: 150, h: 50 }, "left", "bottom"),
// 02-highlighter: empty top right (x 352-536, y 50-114); 150 x 50 right-aligned at x 536, next to the vendor name.
highlighter: slot({ x: 386, y: 50, w: 150, h: 50 }, "right", "top"),
// 04-linea: optional band above the title (x 59.5, y 56-120, at most 150 x 48), sitting on the band's bottom edge.
linea: slot({ x: 59.5, y: 72, w: 150, h: 48 }, "left", "bottom"),
// 12-purple-pop: no slot; at most 34 tall at x 59.5, centred on the vendor name (baseline 98.5). A mark pushes the
// name right (40 wide); a wordmark replaces the name (the area up to the INVOICE NO pill at x 312.8).
"purple-pop": slot({ x: 59.5, y: 71, w: 200, h: 34 }, "left", "middle", { x: 59.5, y: 71, w: 40, h: 34 }),
};
/** The template's slot: a measured reference placeholder, or the hand-written opt-in area, or null (Classic). */
export function slotFor(id: TemplateId): TemplateSlot | null {
return SLOTS[id] ?? OPTIN_SLOTS[id] ?? null;
}
+49
View File
@@ -0,0 +1,49 @@
/** reference page the slot boxes are measured on (pt, top-left origin). */
export const SLOT_PAGE = { w: 595.5, h: 842.25 } as const;
export type TemplateId =
| "classic"
| "marble"
| "highlighter"
| "monolith"
| "linea"
| "monogram"
| "serenity"
| "crimson-grid"
| "cobalt-stripe"
| "slate-band"
| "teal-swoosh"
| "tangerine-ledger"
| "purple-pop"
| "citrus-split";
/** A rectangle in page points from the top-left corner. */
export interface Box {
x: number;
y: number;
w: number;
h: number;
}
export type SlotAnchor = "left" | "right" | "center";
export type SlotVAlign = "top" | "middle" | "bottom";
/** Where a template's logo goes: the ink boxes of its (reference) placeholder. */
export interface TemplateSlot {
page: { w: number; h: number };
/** The icon alone, for placeholders that are icon + name. Seals use the whole seal; text-only placeholders have none. */
mark: Box | null;
/** The whole placeholder: icon + brand name, or the text for text-only placeholders. */
lockup: Box | null;
/** Ink pixels / box area of `mark` and `lockup`. */
markDensity: number;
lockupDensity: number;
anchor: SlotAnchor;
vAlign: SlotVAlign;
/** The slot sits on a dark fill: use the knockout logo, or a white chip behind a logo without alpha. */
darkSurface: boolean;
/** The template has no placeholder; the position is offered as an option. */
optIn?: boolean;
/** Whether an opt-in logo is shown unless the user turns it off. */
defaultOn?: boolean;
}