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.
264 lines
11 KiB
JavaScript
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();
|