Files
Voiced/scripts/logo/contact-sheet.mjs
xavierk c3e2e05e89 Start the setup wizard blank and add a Run setup again button
The first-run seed no longer inserts the original vendor's name, address, contact
details, PAN or bank account; the wizard now starts empty and requires a name and
state. Settings > Data gets a Run setup again button that clears the onboarded flag.
Re-running the wizard keeps the active invoice series unless the prefix or padding
changes. The sample logo moves out of public/ so it no longer ships in the app.
2026-10-05 09:39:02 +05:30

264 lines
11 KiB
JavaScript

// Draws the sign-off sheet: for every template with a logo slot, the reference 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 sampleRaw = await loadImage(readFileSync(path.join(ROOT, "src/pdf/testing/fixtures/sample-wordmark-logo.png")));
const sampleCanvas = createCanvas(sampleRaw.width, sampleRaw.height);
sampleCanvas.getContext("2d").drawImage(sampleRaw, 0, 0);
const syn = syntheticLogos(createCanvas);
const logos = [
{ label: "Test Vendor wordmark", ...trimmed(createCanvas, sampleCanvas), 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) }));
// Reference 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 (reference 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 reference placeholder). Green = logo box as placed.", 8, 40);
const heads = ["reference 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" : "reference 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();
// Reference 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();