// 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]); // The region covers the placeholder and, for a text-only one, its (larger) allowance. const L = slot.textOnly ? { x: Math.min(slot.lockup.x, slot.allowance.x), y: Math.min(slot.lockup.y, slot.allowance.y), w: 0, h: 0 } : slot.lockup; if (slot.textOnly) { L.w = Math.max(slot.lockup.x + slot.lockup.w, slot.allowance.x + slot.allowance.w) - L.x; L.h = Math.max(slot.lockup.y + slot.lockup.h, slot.allowance.y + slot.allowance.h) - L.y; } 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 = measured placeholder box, blue = mark box, orange dashed = allowance of a text-only placeholder, red 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)}`, ...(slot.textOnly ? [`text-only: allowance ${f(slot.allowance)}`] : []), `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.textOnly) box(ctx, slot.allowance, region, "#e08a00", true); 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();