Measure Canva logo placeholders and add slot-based logo placement

scripts/logo/measure-placeholders rasterises the Canva 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 Arun P logo and three test logos.
This commit is contained in:
2026-10-04 06:40:10 +05:30
parent 74ba04e17f
commit 490c5b9009
11 changed files with 984 additions and 21 deletions
+3 -1
View File
@@ -16,7 +16,9 @@
"app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol",
"app:build:windows:offline": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol --config '{\"bundle\":{\"windows\":{\"webviewInstallMode\":{\"type\":\"offlineInstaller\"}}}}'",
"fonts:verify": "node scripts/fonts/verify.mjs",
"fonts:manifest": "node scripts/fonts/build-manifest.mjs"
"fonts:manifest": "node scripts/fonts/build-manifest.mjs",
"logo:measure": "node scripts/logo/measure-placeholders.mjs",
"logo:sheet": "node scripts/logo/contact-sheet.mjs"
},
"dependencies": {
"@carbon/icons-react": "^11.89.0",
+254
View File
@@ -0,0 +1,254 @@
// Draws the sign-off sheet: for every template with a logo slot, the Canva placeholder next to four test logos
// placed by computeLogoPlacement. Run: npm run logo:sheet. Output: /tmp/invoice-c2/contact-sheet.png
import { build } from "esbuild";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import { OUT_DIR, ROOT, SCALE, TEMPLATE_PDF, loadCanvas, renderPage } from "./lib.mjs";
const PX = 3; // sheet pixels per pt
const PAD = 14; // context around the slot, pt
const LABEL_W = 170;
const HEAD_H = 54;
const GUTTER = 8;
/** Bundles the TypeScript placement code (type-only imports and all) so plain node can run it. */
async function loadPlacement() {
const out = await build({
stdin: {
contents: `export { computeLogoPlacement } from "./src/pdf/engine/logo.ts";
export { slotFor } from "./src/pdf/templates/slots.optin.ts";`,
resolveDir: ROOT,
loader: "ts",
},
bundle: true,
write: false,
format: "esm",
platform: "node",
logLevel: "error",
});
const code = out.outputFiles[0].text;
return import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
}
const INK_ALPHA = 32;
/** Trim to the ink box (alpha > 32, ignoring lines with under 2 ink pixels) and measure like the Rust pipeline. */
function inkInfo(img) {
const { width: w, height: h } = img;
const d = img.getContext("2d").getImageData(0, 0, w, h).data;
const rows = new Uint32Array(h), cols = new Uint32Array(w);
let total = 0;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (d[(y * w + x) * 4 + 3] > INK_ALPHA) { rows[y]++; cols[x]++; total++; }
const first = (a) => a.findIndex((v) => v >= 2);
const last = (a) => a.length - 1 - [...a].reverse().findIndex((v) => v >= 2);
const box = { x: first(cols), y: first(rows), w: 0, h: 0 };
box.w = last(cols) - box.x + 1;
box.h = last(rows) - box.y + 1;
return { box, aspect: box.w / box.h, density: total / (box.w * box.h) };
}
function trimmed(createCanvas, img) {
const { box, aspect, density } = inkInfo(img);
const c = createCanvas(box.w, box.h);
c.getContext("2d").drawImage(img, box.x, box.y, box.w, box.h, 0, 0, box.w, box.h);
return { canvas: c, aspect, density };
}
/** Same pixels in white, for dark surfaces. */
function knockout(createCanvas, c) {
const k = createCanvas(c.width, c.height);
const ctx = k.getContext("2d");
ctx.drawImage(c, 0, 0);
ctx.globalCompositeOperation = "source-in";
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, c.width, c.height);
return k;
}
function syntheticLogos(createCanvas) {
const S = 400;
const mk = (w, h, draw) => {
const c = createCanvas(w, h);
draw(c.getContext("2d"), w, h);
return c;
};
const roundRect = (ctx, x, y, w, h, r) => {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
};
const square = mk(S, S, (ctx) => {
ctx.fillStyle = "#0b5f8a";
roundRect(ctx, 0, 0, S, S, 70);
ctx.fill();
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(S / 2, S / 2, 95, 0, Math.PI * 2);
ctx.fill();
});
const emblem = mk(S, S, (ctx) => {
ctx.strokeStyle = "#8a2b0b";
ctx.lineWidth = 22;
ctx.beginPath();
ctx.arc(S / 2, S / 2, S / 2 - 11, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = "#8a2b0b";
ctx.beginPath();
ctx.arc(S / 2, S / 2, 46, 0, Math.PI * 2);
ctx.fill();
});
const tall = mk(S / 2, S, (ctx, w, h) => {
ctx.fillStyle = "#1f6b3a";
for (let i = 0; i < 4; i++) {
roundRect(ctx, 14, 14 + i * (h / 4), w - 28, h / 4 - 40, 18);
ctx.fill();
}
});
return { square, emblem, tall };
}
async function main() {
const { createCanvas, loadImage } = await loadCanvas();
const { computeLogoPlacement, slotFor } = await loadPlacement();
mkdirSync(OUT_DIR, { recursive: true });
const arunRaw = await loadImage(readFileSync(path.join(ROOT, "public/brand/arun-p-logo.png")));
const arunCanvas = createCanvas(arunRaw.width, arunRaw.height);
arunCanvas.getContext("2d").drawImage(arunRaw, 0, 0);
const syn = syntheticLogos(createCanvas);
const logos = [
{ label: "Arun P wordmark", ...trimmed(createCanvas, arunCanvas), kind: "wordmark", includesName: true },
{ label: "Square mark", ...trimmed(createCanvas, syn.square), kind: "mark", includesName: false },
{ label: "Round emblem", ...trimmed(createCanvas, syn.emblem), kind: "mark", includesName: false },
{ label: "Tall logo", ...trimmed(createCanvas, syn.tall), kind: "tall", includesName: false },
].map((l) => ({ ...l, knockout: knockout(createCanvas, l.canvas) }));
// Canva placeholders first, then the opt-in areas.
const ids = Object.keys(TEMPLATE_PDF)
.filter((id) => slotFor(id))
.sort((a, b) => Number(!!slotFor(a).optIn) - Number(!!slotFor(b).optIn) || TEMPLATE_PDF[a] - TEMPLATE_PDF[b]);
const rows = [];
for (const id of ids) {
const slot = slotFor(id);
const page = await renderPage(TEMPLATE_PDF[id]);
const L = slot.lockup;
const region = {
x: Math.max(0, Math.floor(L.x - PAD)),
y: Math.max(0, Math.floor(L.y - PAD)),
};
region.w = Math.ceil(L.x + L.w + PAD) - region.x;
region.h = Math.ceil(L.y + L.h + PAD) - region.y;
rows.push({ id, slot, page, region });
}
const cellW = Math.max(...rows.map((r) => r.region.w)) * PX;
const sheetW = LABEL_W + (cellW + GUTTER) * 5 + GUTTER;
const rowH = (r) => r.region.h * PX + GUTTER;
const sheetH = HEAD_H + rows.reduce((s, r) => s + rowH(r), 0);
const sheet = createCanvas(sheetW, sheetH);
const ctx = sheet.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, sheetW, sheetH);
ctx.fillStyle = "#111111";
ctx.font = "bold 16px sans-serif";
ctx.fillText("Logo placement sign-off sheet (Canva placeholder, then four test logos)", 8, 22);
ctx.font = "12px sans-serif";
ctx.fillText("Red outline = measured placeholder box, blue = mark box. Dashed = opt-in area (no Canva placeholder). Green = logo box as placed.", 8, 40);
const heads = ["Canva placeholder", ...logos.map((l) => `${l.label} (${l.aspect.toFixed(2)}:1, d ${l.density.toFixed(2)})`)];
ctx.font = "bold 11px sans-serif";
heads.forEach((t, i) => ctx.fillText(t, LABEL_W + GUTTER + i * (cellW + GUTTER), HEAD_H - 6));
const box = (c, b, rx, ry, color, dash) => {
c.strokeStyle = color;
c.lineWidth = 1.5;
c.setLineDash(dash ? [5, 4] : []);
c.strokeRect((b.x - rx.x) * PX, (b.y - rx.y) * PX, b.w * PX, b.h * PX);
c.setLineDash([]);
};
let y = HEAD_H;
for (const { id, slot, page, region } of rows) {
const h = region.h * PX;
ctx.fillStyle = "#111111";
ctx.font = "bold 13px sans-serif";
ctx.fillText(id, 8, y + 18);
ctx.font = "11px sans-serif";
const f = (b) => (b ? `${b.w.toFixed(1)} x ${b.h.toFixed(1)} pt` : "none");
const lines = [
slot.optIn ? "opt-in, off by default" : "Canva placeholder",
`lockup ${f(slot.lockup)}`,
`mark ${f(slot.mark)}`,
`density ${slot.lockupDensity.toFixed(2)} / ${slot.markDensity.toFixed(2)}`,
`${slot.anchor}, ${slot.vAlign}${slot.darkSurface ? ", dark surface" : ""}`,
];
lines.forEach((t, i) => ctx.fillText(t, 8, y + 36 + i * 15));
const cell = (col) => {
const cx = LABEL_W + GUTTER + col * (cellW + GUTTER);
ctx.save();
ctx.beginPath();
ctx.rect(cx, y, cellW, h);
ctx.clip();
ctx.translate(cx, y);
ctx.drawImage(page, region.x * SCALE, region.y * SCALE, region.w * SCALE, region.h * SCALE, 0, 0, region.w * PX, h);
return { cx, restore: () => ctx.restore() };
};
// Background colour at the region's corner, for painting the placeholder out of the rendering cells.
const bgOf = () => {
const c = createCanvas(region.w * SCALE, region.h * SCALE);
c.getContext("2d").drawImage(page, region.x * SCALE, region.y * SCALE, region.w * SCALE, region.h * SCALE, 0, 0, region.w * SCALE, region.h * SCALE);
const d = c.getContext("2d").getImageData(0, 0, 1, 1).data;
return `rgb(${d[0]},${d[1]},${d[2]})`;
};
const bg = bgOf();
// Canva placeholder cell.
{
const c = cell(0);
if (slot.optIn) box(ctx, slot.lockup, region, "#dd0000", true);
else {
box(ctx, slot.lockup, region, "#dd0000");
if (slot.mark && slot.mark !== slot.lockup && slot.mark.w !== slot.lockup.w) box(ctx, slot.mark, region, "#0066cc");
}
c.restore();
}
logos.forEach((logo, i) => {
const c = cell(i + 1);
if (!slot.optIn) {
// Paint the placeholder out, then place the logo where computeLogoPlacement says.
ctx.fillStyle = bg;
const L = slot.lockup;
ctx.fillRect((L.x - region.x - 1) * PX, (L.y - region.y - 1) * PX, (L.w + 2) * PX, (L.h + 2) * PX);
} else box(ctx, slot.lockup, region, "#dd0000", true);
const p = computeLogoPlacement({
logo: { aspect: logo.aspect, density: logo.density, kind: logo.kind, includesName: logo.includesName, hasKnockout: true },
slot,
});
const px = (p.x - region.x) * PX, py = (p.y - region.y) * PX;
if (p.variant === "chip") {
ctx.fillStyle = "#ffffff";
ctx.fillRect((p.chip.x - region.x) * PX, (p.chip.y - region.y) * PX, p.chip.w * PX, p.chip.h * PX);
}
ctx.drawImage(p.variant === "knockout" ? logo.knockout : logo.canvas, px, py, p.w * PX, p.h * PX);
box(ctx, p.box, region, "#00aa00");
ctx.fillStyle = "#00aa00";
ctx.font = "10px sans-serif";
ctx.fillText(`${p.w.toFixed(1)} x ${p.h.toFixed(1)} ${p.slotKind}`, 3, h - 4);
c.restore();
});
y += rowH({ region });
}
const outPath = path.join(OUT_DIR, "contact-sheet.png");
writeFileSync(outPath, sheet.toBuffer("image/png"));
console.log(`${ids.length} rows -> ${outPath} (${sheetW} x ${sheetH})`);
}
await main();
+50
View File
@@ -0,0 +1,50 @@
// Shared helpers for the logo scripts: rasterise a Canva PDF page with pdf.js and @napi-rs/canvas.
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export const PDF_DIR = path.join(ROOT, "templates", "Modern Neutral Invoice Template");
export const SCALE = 4;
export const PAGE = { w: 595.5, h: 842.25 };
export const OUT_DIR = "/tmp/invoice-c2";
export async function loadCanvas() {
try {
return await import("@napi-rs/canvas");
} catch {
console.error("@napi-rs/canvas is not installed (it is an optional dev dependency); run npm install");
process.exit(1);
}
}
/** Renders page 1 of the Canva PDF `n` at SCALE x. Returns the canvas. */
export async function renderPage(n, scale = SCALE) {
const { createCanvas } = await loadCanvas();
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const data = new Uint8Array(readFileSync(path.join(PDF_DIR, `${n}.pdf`)));
const doc = await pdfjs.getDocument({ data, isEvalSupported: false, verbosity: 0 }).promise;
const page = await doc.getPage(1);
const vp = page.getViewport({ scale });
const canvas = createCanvas(Math.round(vp.width), Math.round(vp.height));
await page.render({ canvasContext: canvas.getContext("2d"), viewport: vp, canvas }).promise;
return canvas;
}
/** Copies a pt rectangle of a page canvas into a new canvas (same scale). */
export async function cropCanvas(canvas, rect, scale = SCALE) {
const { createCanvas } = await loadCanvas();
const x = Math.round(rect.x * scale);
const y = Math.round(rect.y * scale);
const w = Math.round(rect.w * scale);
const h = Math.round(rect.h * scale);
const out = createCanvas(w, h);
out.getContext("2d").drawImage(canvas, x, y, w, h, 0, 0, w, h);
return out;
}
/** Canva PDF number of each template (docs/templates/README.md). */
export const TEMPLATE_PDF = {
marble: 1, highlighter: 2, monolith: 3, linea: 4, monogram: 5, serenity: 6, "crimson-grid": 7,
"cobalt-stripe": 8, "slate-band": 9, "teal-swoosh": 10, "tangerine-ledger": 11, "purple-pop": 12, "citrus-split": 13,
};
+225
View File
@@ -0,0 +1,225 @@
// Measures the logo placeholder of each Canva template PDF and writes src/pdf/templates/slots.generated.ts.
// Run: npm run logo:measure. Crops with the measured boxes go to /tmp/invoice-c2/crops for eyeballing.
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { OUT_DIR, PAGE, ROOT, SCALE, cropCanvas, loadCanvas, renderPage } from "./lib.mjs";
/** Colour distance (euclidean, 0-255 RGB) from the local background above which a pixel is ink. */
const INK_DISTANCE = 40;
/** Columns of empty pixels at least this wide (pt) separate the icon from the name in a lockup. */
const MARK_GAP_PT = 5;
/** Connected ink blobs smaller than this many pixels (at 4x) are paper texture or stray specks, not logo. */
const MIN_BLOB_PX = 30;
/*
* Search rectangles in page points (top-left origin). They come from the "Layout" / "Mapping to Voiced
* fields" sections of docs/templates/NN-*.md and were then tightened by looking at the crops, so that
* they hold the placeholder and nothing else:
* 3 Monolith 'CAVEIN' is the first line of a right-aligned stack; the date line starts at y 74, so the rect stops at y 70.
* 5 Monogram the ring (docs: x 387-531, y 58-198); the ring really starts at y 80.
* 6 Serenity the stamp (docs: x 441-531, y 128-218).
* 7 Crimson Grid flame (x 420-452, y 53-99) plus the 2-line name.
* 8 Cobalt swoosh (x 59.5-94, y 59.5-94) plus name and tagline; the grey rule starts at y 112, below the rect.
* 9 Slate Band white mark + name inside the dark band (the band ends at y 103).
* 10 Teal Swoosh mark + name + tagline; the corner swoosh art is far right (x > 350), outside the rect.
* 11 Tangerine the 'Rimberio' text. Canva leaves a 1 px tick at about (62, 95), so the rect stops at y 92.
* 13 Citrus Split mark + 2-line name on the grey column; the halftone chevrons start at x 487, outside the rect.
* kind: 'lockup' = icon + name (split into mark and lockup), 'seal' = one round placeholder (mark = lockup),
* 'text' = a text-only placeholder (no mark).
*/
const SEARCH_RECTS = {
3: { id: "monolith", rect: { x: 480, y: 46, w: 95, h: 24 }, kind: "text", anchor: "right", vAlign: "middle", dark: false },
5: { id: "monogram", rect: { x: 370, y: 60, w: 178, h: 152 }, kind: "seal", anchor: "right", vAlign: "middle", dark: false },
6: { id: "serenity", rect: { x: 430, y: 118, w: 115, h: 108 }, kind: "seal", anchor: "right", vAlign: "middle", dark: false },
7: { id: "crimson-grid", rect: { x: 405, y: 45, w: 140, h: 60 }, kind: "lockup", anchor: "right", vAlign: "bottom", dark: false },
8: { id: "cobalt-stripe", rect: { x: 50, y: 50, w: 215, h: 52 }, kind: "lockup", anchor: "left", vAlign: "middle", dark: false },
9: { id: "slate-band", rect: { x: 50, y: 46, w: 130, h: 50 }, kind: "lockup", anchor: "left", vAlign: "middle", dark: true },
10: { id: "teal-swoosh", rect: { x: 50, y: 52, w: 160, h: 46 }, kind: "lockup", anchor: "left", vAlign: "middle", dark: false },
11: { id: "tangerine-ledger", rect: { x: 50, y: 55, w: 120, h: 37 }, kind: "text", anchor: "left", vAlign: "middle", dark: false },
13: { id: "citrus-split", rect: { x: 325, y: 65, w: 165, h: 55 }, kind: "lockup", anchor: "left", vAlign: "middle", dark: false },
};
const TEMPLATE_IDS = [
"classic", "marble", "highlighter", "monolith", "linea", "monogram", "serenity", "crimson-grid",
"cobalt-stripe", "slate-band", "teal-swoosh", "tangerine-ledger", "purple-pop", "citrus-split",
];
const median = (arr) => {
const s = Uint8Array.from(arr).sort();
return s[s.length >> 1];
};
/** Ink mask of an RGBA crop: pixels far from the median colour of the crop's border. */
function inkMask(img, w, h) {
const border = [[], [], []];
const push = (x, y) => {
const i = (y * w + x) * 4;
for (let c = 0; c < 3; c++) border[c].push(img[i + c]);
};
for (let x = 0; x < w; x++) { push(x, 0); push(x, h - 1); }
for (let y = 1; y < h - 1; y++) { push(0, y); push(w - 1, y); }
const bg = border.map(median);
const mask = new Uint8Array(w * h);
for (let p = 0; p < w * h; p++) {
const i = p * 4;
const d = Math.hypot(img[i] - bg[0], img[i + 1] - bg[1], img[i + 2] - bg[2]);
mask[p] = d > INK_DISTANCE ? 1 : 0;
}
dropSpecks(mask, w, h);
return { mask, bg };
}
/** Clears 8-connected blobs under MIN_BLOB_PX in place (Serenity's paper has dark flecks). */
function dropSpecks(mask, w, h) {
const seen = new Uint8Array(w * h);
const stack = [];
for (let start = 0; start < w * h; start++) {
if (!mask[start] || seen[start]) continue;
const blob = [];
seen[start] = 1;
stack.push(start);
while (stack.length) {
const p = stack.pop();
blob.push(p);
const x = p % w, y = (p - x) / w;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
const q = ny * w + nx;
if (mask[q] && !seen[q]) { seen[q] = 1; stack.push(q); }
}
}
}
if (blob.length < MIN_BLOB_PX) for (const p of blob) mask[p] = 0;
}
}
/** Bounding box (px) and ink count of the mask within columns [x0, x1). */
function inkBox(mask, w, h, x0 = 0, x1 = w) {
let minX = Infinity, minY = Infinity, maxX = -1, maxY = -1, count = 0;
for (let y = 0; y < h; y++) {
for (let x = x0; x < x1; x++) {
if (!mask[y * w + x]) continue;
count++;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
if (count === 0) return null;
return { minX, minY, maxX: maxX + 1, maxY: maxY + 1, count };
}
/** First run of ink columns, ending at the first empty gap of MARK_GAP_PT or more. */
function firstColumnGroup(mask, w, h, box) {
const gap = MARK_GAP_PT * SCALE;
let empty = 0;
for (let x = box.minX; x < box.maxX; x++) {
let any = false;
for (let y = 0; y < h && !any; y++) any = mask[y * w + x] === 1;
empty = any ? 0 : empty + 1;
if (empty >= gap) return x - empty + 1;
}
return null;
}
const r2 = (v) => Math.round(v * 100) / 100;
function toPt(rect, box) {
return {
x: r2(rect.x + box.minX / SCALE),
y: r2(rect.y + box.minY / SCALE),
w: r2((box.maxX - box.minX) / SCALE),
h: r2((box.maxY - box.minY) / SCALE),
};
}
async function main() {
const { createCanvas } = await loadCanvas();
const cropsDir = path.join(OUT_DIR, "crops");
mkdirSync(cropsDir, { recursive: true });
const slots = {};
const rows = [];
for (const [n, spec] of Object.entries(SEARCH_RECTS)) {
const page = await renderPage(Number(n));
const crop = await cropCanvas(page, spec.rect);
const w = crop.width, h = crop.height;
const img = crop.getContext("2d").getImageData(0, 0, w, h).data;
const { mask, bg } = inkMask(img, w, h);
const all = inkBox(mask, w, h);
if (!all) throw new Error(`template ${n} (${spec.id}): no ink in the search rect`);
const touches = all.minX === 0 || all.minY === 0 || all.maxX === w || all.maxY === h;
if (touches) console.warn(`WARNING ${n} ${spec.id}: ink reaches the search rect edge; the rect may cut or include other art`);
const lockupBox = toPt(spec.rect, all);
const lockupDensity = all.count / ((all.maxX - all.minX) * (all.maxY - all.minY));
let markBox = null;
let markDensity = 0;
const boxes = [{ box: all, color: "red" }];
if (spec.kind === "seal") {
markBox = lockupBox;
markDensity = lockupDensity;
} else if (spec.kind === "lockup") {
const split = firstColumnGroup(mask, w, h, all);
if (split === null) throw new Error(`template ${n} (${spec.id}): no gap between mark and name`);
const m = inkBox(mask, w, h, all.minX, split);
markBox = toPt(spec.rect, m);
markDensity = m.count / ((m.maxX - m.minX) * (m.maxY - m.minY));
boxes.push({ box: m, color: "blue" });
}
const rounded = (v) => Math.round(v * 1000) / 1000;
slots[spec.id] = {
page: { w: PAGE.w, h: PAGE.h },
mark: markBox,
lockup: lockupBox,
markDensity: rounded(markDensity),
lockupDensity: rounded(lockupDensity),
anchor: spec.anchor,
vAlign: spec.vAlign,
darkSurface: spec.dark,
};
rows.push({ n, id: spec.id, mark: markBox, lockup: lockupBox, markDensity, lockupDensity, anchor: spec.anchor, dark: spec.dark, bg });
// Crop for review: the search rect with the measured boxes (red = lockup, blue = mark).
const out = createCanvas(w, h);
const ctx = out.getContext("2d");
ctx.drawImage(crop, 0, 0);
ctx.lineWidth = 2;
for (const { box, color } of boxes) {
ctx.strokeStyle = color;
ctx.strokeRect(box.minX - 1, box.minY - 1, box.maxX - box.minX + 2, box.maxY - box.minY + 2);
}
writeFileSync(path.join(cropsDir, `${n}-${spec.id}.png`), out.toBuffer("image/png"));
}
const body = TEMPLATE_IDS.map((id) => {
const s = slots[id];
return ` ${JSON.stringify(id)}: ${s ? JSON.stringify(s) : "null"},`;
}).join("\n");
const ts = `// Generated by \`npm run logo:measure\` from the Canva 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 Canva 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> = {
${body}
};
`;
writeFileSync(path.join(ROOT, "src/pdf/templates/slots.generated.ts"), ts);
const f = (b) => (b ? `${b.x},${b.y} ${b.w}x${b.h}` : "-");
console.log("n id mark lockup markD lockD anchor dark bg");
for (const r of rows) {
console.log(
`${String(r.n).padEnd(2)} ${r.id.padEnd(17)} ${f(r.mark).padEnd(23)} ${f(r.lockup).padEnd(23)} ${r.markDensity.toFixed(3)} ${r.lockupDensity.toFixed(3)} ${r.anchor.padEnd(6)} ${r.dark ? "yes " : "no "} ${r.bg.join(",")}`,
);
}
console.log(`crops: ${cropsDir}`);
}
await main();
+213
View File
@@ -0,0 +1,213 @@
import { describe, expect, it } from "vitest";
import { MIN_LOGO_HEIGHT, computeLogoPlacement, type SlotLogoInput } from "./logo";
import { SLOTS } from "../templates/slots.generated";
import { OPTIN_SLOTS, slotFor } from "../templates/slots.optin";
import { SLOT_PAGE, type Box, type TemplateId, type TemplateSlot } from "../templates/slots.types";
const measured = Object.entries(SLOTS).filter((e): e is [TemplateId, TemplateSlot] => e[1] !== null);
const optIn = Object.entries(OPTIN_SLOTS) as [TemplateId, TemplateSlot][];
const logo = (over: Partial<SlotLogoInput>): SlotLogoInput => ({
aspect: 1,
density: 0.5,
kind: "mark",
includesName: false,
hasKnockout: true,
...over,
});
const ARUN_P = logo({ aspect: 4.62, density: 0.437, kind: "wordmark", includesName: true });
const inside = (inner: Box, outer: Box, eps = 1e-6) =>
inner.x >= outer.x - eps && inner.y >= outer.y - eps && inner.x + inner.w <= outer.x + outer.w + eps && inner.y + inner.h <= outer.y + outer.h + eps;
const closeBox = (a: Box, b: Box, tol = 0.5) => {
for (const k of ["x", "y", "w", "h"] as const) expect(Math.abs(a[k] - b[k]), `${k}: ${a[k]} vs ${b[k]}`).toBeLessThanOrEqual(tol);
};
describe("slots.generated", () => {
it("has a measured slot for the nine templates with a Canva placeholder, none for the rest", () => {
expect(measured.map(([id]) => id).sort()).toEqual(
["citrus-split", "cobalt-stripe", "crimson-grid", "monogram", "monolith", "serenity", "slate-band", "tangerine-ledger", "teal-swoosh"],
);
for (const id of ["classic", "marble", "highlighter", "linea", "purple-pop"] as const) expect(SLOTS[id]).toBeNull();
});
it("keeps every box on the page, with the mark inside the lockup", () => {
for (const [id, s] of measured) {
const page = { x: 0, y: 0, w: SLOT_PAGE.w, h: SLOT_PAGE.h };
expect(s.lockup && inside(s.lockup, page), id).toBe(true);
if (s.mark && s.lockup) expect(inside(s.mark, s.lockup, 0.01), id).toBe(true);
}
});
it("only Slate Band is a dark surface", () => {
expect(measured.filter(([, s]) => s.darkSurface).map(([id]) => id)).toEqual(["slate-band"]);
});
});
describe("slots.optin", () => {
it("covers the four templates without a placeholder, all opt-in and off by default", () => {
expect(optIn.map(([id]) => id).sort()).toEqual(["highlighter", "linea", "marble", "purple-pop"]);
for (const [id, s] of optIn) {
expect(s.optIn, id).toBe(true);
expect(s.defaultOn, id).toBe(false);
}
});
it("slotFor prefers a measured slot and gives Classic none", () => {
expect(slotFor("monogram")).toBe(SLOTS.monogram);
expect(slotFor("marble")).toBe(OPTIN_SLOTS.marble);
expect(slotFor("classic")).toBeNull();
});
});
describe("computeLogoPlacement identity: the placeholder itself comes back as its own box", () => {
for (const [id, s] of measured) {
it(`${id} lockup`, () => {
const l = s.lockup!;
const p = computeLogoPlacement({
logo: logo({ aspect: l.w / l.h, density: s.lockupDensity, kind: "wordmark", includesName: true }),
slot: s,
});
expect(p.slotKind).toBe("lockup");
closeBox(p.box, l);
});
if (s.mark) {
it(`${id} mark`, () => {
const m = s.mark!;
const p = computeLogoPlacement({ logo: logo({ aspect: m.w / m.h, density: s.markDensity }), slot: s });
expect(p.slotKind).toBe("mark");
closeBox(p.box, m);
});
}
}
});
describe("computeLogoPlacement on every slot", () => {
const all: [TemplateId, TemplateSlot][] = [...measured, ...optIn];
it("Arun P never exceeds its box and is never heavier than the placeholder ink", () => {
for (const [id, s] of all) {
const p = computeLogoPlacement({ logo: ARUN_P, slot: s });
expect(p.slotKind).toBe("lockup");
expect(inside(p.box, s.lockup!), id).toBe(true);
if (!s.optIn) {
const ink = p.w * p.h * ARUN_P.density;
const placeholderInk = s.lockup!.w * s.lockup!.h * s.lockupDensity;
expect(ink, `${id}: ${ink.toFixed(1)} vs ${placeholderInk.toFixed(1)}`).toBeLessThanOrEqual(placeholderInk * 1.05);
}
}
});
it("a square mark, a round emblem and a tall logo stay inside the mark box (the lockup box without one)", () => {
const shapes = [logo({ aspect: 1, density: 0.6 }), logo({ aspect: 1, density: 0.2, kind: "mark" }), logo({ aspect: 0.5, density: 0.5, kind: "tall" })];
for (const [id, s] of all) {
for (const l of shapes) {
const p = computeLogoPlacement({ logo: l, slot: s });
expect(inside(p.box, (s.mark ?? s.lockup)!), `${id} aspect ${l.aspect}`).toBe(true);
expect(p.w / p.h).toBeCloseTo(l.aspect, 6);
}
}
});
it("falls back to the lockup box for a mark-less slot even when the logo has no name", () => {
const s = SLOTS.monolith!;
expect(s.mark).toBeNull();
const p = computeLogoPlacement({ logo: logo({ aspect: 1 }), slot: s });
expect(p.slotKind).toBe("lockup");
expect(inside(p.box, s.lockup!)).toBe(true);
});
it("a denser logo is drawn smaller, at most down to 70 %, and a lighter one is not enlarged", () => {
const s = SLOTS["cobalt-stripe"]!;
const m = s.mark!;
const dense = computeLogoPlacement({ logo: logo({ aspect: m.w / m.h, density: 1 }), slot: s });
expect(dense.h / m.h).toBeCloseTo(Math.max(0.7, Math.sqrt(s.markDensity)), 6);
const heavy = computeLogoPlacement({ logo: logo({ aspect: m.w / m.h, density: 50 }), slot: s });
expect(heavy.h / m.h).toBeCloseTo(0.7, 6);
const light = computeLogoPlacement({ logo: logo({ aspect: m.w / m.h, density: 0.01 }), slot: s });
expect(light.h).toBeCloseTo(m.h, 6);
});
it("aligns by the slot's anchor and vAlign", () => {
const s = SLOTS["crimson-grid"]!; // right, bottom
const m = s.mark!;
const p = computeLogoPlacement({ logo: logo({ aspect: 0.5, density: 0.1 }), slot: s });
expect(p.x + p.w).toBeCloseTo(m.x + m.w, 6);
expect(p.y + p.h).toBeCloseTo(m.y + m.h, 6);
const left = computeLogoPlacement({ logo: logo({ aspect: 0.5, density: 0.1 }), slot: { ...s, anchor: "left", vAlign: "top" } });
expect(left.x).toBeCloseTo(m.x, 6);
expect(left.y).toBeCloseTo(m.y, 6);
const mid = computeLogoPlacement({ logo: logo({ aspect: 0.5, density: 0.1 }), slot: { ...s, anchor: "center", vAlign: "middle" } });
expect(mid.x + mid.w / 2).toBeCloseTo(m.x + m.w / 2, 6);
expect(mid.y + mid.h / 2).toBeCloseTo(m.y + m.h / 2, 6);
});
});
describe("computeLogoPlacement surface", () => {
const slate = SLOTS["slate-band"]!;
it("uses the knockout on a dark surface, a white chip without one, and the print image elsewhere", () => {
expect(computeLogoPlacement({ logo: logo({ hasKnockout: true }), slot: slate }).variant).toBe("knockout");
const chip = computeLogoPlacement({ logo: logo({ hasKnockout: false }), slot: slate });
expect(chip.variant).toBe("chip");
expect(chip.chip).toEqual({ x: chip.x - 4, y: chip.y - 4, w: chip.w + 8, h: chip.h + 8 });
for (const [id, s] of measured) {
if (id === "slate-band") continue;
expect(computeLogoPlacement({ logo: logo({ hasKnockout: false }), slot: s }).variant, id).toBe("print");
}
expect(computeLogoPlacement({ logo: logo({}), slot: slate }).chip).toBeUndefined();
});
});
describe("computeLogoPlacement legibility floor", () => {
const slot = (h: number): TemplateSlot => ({
page: { ...SLOT_PAGE },
mark: { x: 50, y: 50, w: 100, h },
lockup: { x: 50, y: 50, w: 100, h },
markDensity: 0.25,
lockupDensity: 0.25,
anchor: "left",
vAlign: "middle",
darkSurface: false,
});
it("raises a logo that the weight factor made too short to 14 pt", () => {
// contain gives 16 pt tall; density 1 against 0.25 shrinks it to 11.2 pt.
const p = computeLogoPlacement({ logo: logo({ aspect: 1, density: 1 }), slot: slot(16) });
expect(p.h).toBe(MIN_LOGO_HEIGHT);
expect(p.w).toBe(MIN_LOGO_HEIGHT);
});
it("never grows past the box: a box under 14 pt stays the limit", () => {
const p = computeLogoPlacement({ logo: logo({ aspect: 4, density: 0.25 }), slot: slot(9) });
expect(p.h).toBeCloseTo(9, 6);
expect(p.w).toBeCloseTo(36, 6);
});
it("lets the width follow the floor and clamps it to the box", () => {
const p = computeLogoPlacement({ logo: logo({ aspect: 10, density: 0.25 }), slot: slot(40) });
// contain: 100 x 10 (width-limited), floor wants 14 x 140, the box width wins.
expect(p.w).toBeCloseTo(100, 6);
expect(p.h).toBeCloseTo(10, 6);
});
});
describe("computeLogoPlacement page scale and available room", () => {
const letter = 612 / 595.28;
it("scales the box about its anchor: a right-aligned slot keeps its right edge", () => {
const s = SLOTS.monogram!;
const m = s.mark!;
const p = computeLogoPlacement({ logo: logo({ aspect: m.w / m.h, density: s.markDensity }), slot: s, scale: letter });
expect(p.w).toBeCloseTo(m.w * letter, 6);
expect(p.h).toBeCloseTo(m.h * letter, 6);
expect(p.x + p.w).toBeCloseTo(m.x + m.w, 6);
});
it("shrinks to the room the layout has", () => {
const s = SLOTS["teal-swoosh"]!;
const p = computeLogoPlacement({ logo: ARUN_P, slot: s, available: { w: 80, h: 100 } });
expect(p.w).toBeLessThanOrEqual(80 + 1e-9);
expect(p.x).toBeCloseTo(s.lockup!.x, 6);
});
});
+13 -13
View File
@@ -1,49 +1,49 @@
import { describe, expect, it } from "vitest";
import { computeLogoPlacement } from "./logo";
import { computeClassicLogoPlacement } from "./logo";
const BOX = { w: 170, h: 54 };
const base = { box: BOX, availableWidth: 299, minTextWidth: 160 };
describe("computeLogoPlacement", () => {
describe("computeClassicLogoPlacement", () => {
it("the Arun P trimmed 4.6:1 logo is width-limited and stacks above the text", () => {
const p = computeLogoPlacement({ ...base, aspect: 4.6 });
const p = computeClassicLogoPlacement({ ...base, aspect: 4.6 });
expect(p.w).toBeCloseTo(170, 6);
expect(p.h).toBeCloseTo(170 / 4.6, 6);
expect(p.stacked).toBe(true);
});
it("an untrimmed 3:1 logo is height-limited (54 pt) and still stacks", () => {
const p = computeLogoPlacement({ ...base, aspect: 3 });
const p = computeClassicLogoPlacement({ ...base, aspect: 3 });
expect(p.h).toBe(54);
expect(p.w).toBeCloseTo(162, 6);
expect(p.stacked).toBe(true);
});
it("a square logo is 54 x 54 and sits beside the text", () => {
const p = computeLogoPlacement({ ...base, aspect: 1 });
const p = computeClassicLogoPlacement({ ...base, aspect: 1 });
expect(p).toEqual({ w: 54, h: 54, stacked: false });
});
it("stacks exactly when the text beside the logo would drop under the minimum", () => {
// 299 - 54 - 14 = 231 beside a square; a 2:1 logo (108 wide) leaves 177; a 2.5:1 one (135) leaves 150.
expect(computeLogoPlacement({ ...base, aspect: 2 }).stacked).toBe(false);
expect(computeLogoPlacement({ ...base, aspect: 2.5 }).stacked).toBe(true);
expect(computeClassicLogoPlacement({ ...base, aspect: 2 }).stacked).toBe(false);
expect(computeClassicLogoPlacement({ ...base, aspect: 2.5 }).stacked).toBe(true);
// The boundary: leaves exactly 160.
const w = 299 - 14 - 160;
expect(computeLogoPlacement({ ...base, aspect: w / 54 }).stacked).toBe(false);
expect(computeLogoPlacement({ ...base, aspect: (w + 0.01) / 54 }).stacked).toBe(true);
expect(computeClassicLogoPlacement({ ...base, aspect: w / 54 }).stacked).toBe(false);
expect(computeClassicLogoPlacement({ ...base, aspect: (w + 0.01) / 54 }).stacked).toBe(true);
});
it("gives the logo more room on a wider column (Letter)", () => {
const a4 = computeLogoPlacement({ ...base, aspect: 2.4, availableWidth: 299.28 });
const letter = computeLogoPlacement({ ...base, aspect: 2.4, availableWidth: 316 });
const a4 = computeClassicLogoPlacement({ ...base, aspect: 2.4, availableWidth: 299.28 });
const letter = computeClassicLogoPlacement({ ...base, aspect: 2.4, availableWidth: 316 });
expect(a4.stacked).toBe(true);
expect(letter.stacked).toBe(false);
});
it("never exceeds the box, whatever the aspect", () => {
for (const aspect of [0.2, 0.5, 1, 1.5, 3, 4.6, 10, 40]) {
const p = computeLogoPlacement({ ...base, aspect });
const p = computeClassicLogoPlacement({ ...base, aspect });
expect(p.w).toBeLessThanOrEqual(170 + 1e-9);
expect(p.h).toBeLessThanOrEqual(54 + 1e-9);
expect(p.w / p.h).toBeCloseTo(aspect, 6);
@@ -51,7 +51,7 @@ describe("computeLogoPlacement", () => {
});
it("falls back to the box aspect for a bad aspect", () => {
const p = computeLogoPlacement({ ...base, aspect: Number.NaN });
const p = computeClassicLogoPlacement({ ...base, aspect: Number.NaN });
expect(p.w).toBeCloseTo(170, 6);
expect(p.h).toBeCloseTo(54, 6);
});
+109 -5
View File
@@ -1,3 +1,5 @@
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;
@@ -24,15 +26,117 @@ 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 computeLogoPlacement({ aspect, box, availableWidth, minTextWidth, gap = LOGO_GAP }: LogoPlacementInput): LogoPlacement {
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 };
}
/*
* Seam for Canva templates: slots.generated.ts (measured placeholder boxes, see
* scripts/logo/measure-placeholders.mjs) will supply a template's `logo.box` and may refine the fit.
* Until it exists, every template declares its box by hand in its TemplateDefinition.
/** 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 Canva 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. 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;
const target = 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;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { createElement } from "react";
import { computeLogoPlacement } from "../engine/logo";
import { computeClassicLogoPlacement } from "../engine/logo";
import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry";
import type { AuditIssue } from "../engine/audit";
import type { RenderModel } from "../model/build";
@@ -47,7 +47,7 @@ export async function renderInvoicePdf({ model, prefs }: RenderInvoiceInput, byt
let logo: PlacedLogo | null = null;
if (model.logo && template.logo.defaultOn) {
const placed = computeLogoPlacement({
const placed = computeClassicLogoPlacement({
aspect: model.logo.width / model.logo.height,
box: template.logo.box,
availableWidth: template.logo.slotWidth(frame),
+22
View File
@@ -0,0 +1,22 @@
// Generated by `npm run logo:measure` from the Canva 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 Canva 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";
/*
* Canva 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 (Arun P 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 Canva 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 @@
/** Canva 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 (Canva) 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;
}