Files
Voiced/scripts/logo/contact-sheet.mjs
T
xavierk 3f7c12b7dd 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 Canva 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

264 lines
10 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]);
// 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();