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.
255 lines
9.8 KiB
JavaScript
255 lines
9.8 KiB
JavaScript
// 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();
|