Measure reference logo placeholders and add slot-based logo placement

scripts/logo/measure-placeholders rasterises the reference 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 Test Vendor logo and three test logos.
This commit is contained in:
2026-10-04 06:40:10 +05:30
parent e1edf02448
commit eddf27629f
11 changed files with 984 additions and 21 deletions
+254
View File
@@ -0,0 +1,254 @@
// 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, "public/brand/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]);
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 (reference 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 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)}`,
`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.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();
+50
View File
@@ -0,0 +1,50 @@
// Shared helpers for the logo scripts: rasterise a reference PDF page with pdf.js and @napi-rs/canvas.
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export const PDF_DIR = path.join(ROOT, "templates", "reference");
export const SCALE = 4;
export const PAGE = { w: 595.5, h: 842.25 };
export const OUT_DIR = "/tmp/invoice-c2";
export async function loadCanvas() {
try {
return await import("@napi-rs/canvas");
} catch {
console.error("@napi-rs/canvas is not installed (it is an optional dev dependency); run npm install");
process.exit(1);
}
}
/** Renders page 1 of the reference PDF `n` at SCALE x. Returns the canvas. */
export async function renderPage(n, scale = SCALE) {
const { createCanvas } = await loadCanvas();
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const data = new Uint8Array(readFileSync(path.join(PDF_DIR, `${n}.pdf`)));
const doc = await pdfjs.getDocument({ data, isEvalSupported: false, verbosity: 0 }).promise;
const page = await doc.getPage(1);
const vp = page.getViewport({ scale });
const canvas = createCanvas(Math.round(vp.width), Math.round(vp.height));
await page.render({ canvasContext: canvas.getContext("2d"), viewport: vp, canvas }).promise;
return canvas;
}
/** Copies a pt rectangle of a page canvas into a new canvas (same scale). */
export async function cropCanvas(canvas, rect, scale = SCALE) {
const { createCanvas } = await loadCanvas();
const x = Math.round(rect.x * scale);
const y = Math.round(rect.y * scale);
const w = Math.round(rect.w * scale);
const h = Math.round(rect.h * scale);
const out = createCanvas(w, h);
out.getContext("2d").drawImage(canvas, x, y, w, h, 0, 0, w, h);
return out;
}
/** reference PDF number of each template (docs/templates/README.md). */
export const TEMPLATE_PDF = {
marble: 1, highlighter: 2, monolith: 3, linea: 4, monogram: 5, serenity: 6, "crimson-grid": 7,
"cobalt-stripe": 8, "slate-band": 9, "teal-swoosh": 10, "tangerine-ledger": 11, "purple-pop": 12, "citrus-split": 13,
};
+225
View File
@@ -0,0 +1,225 @@
// Measures the logo placeholder of each reference template PDF and writes src/pdf/templates/slots.generated.ts.
// Run: npm run logo:measure. Crops with the measured boxes go to /tmp/invoice-c2/crops for eyeballing.
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { OUT_DIR, PAGE, ROOT, SCALE, cropCanvas, loadCanvas, renderPage } from "./lib.mjs";
/** Colour distance (euclidean, 0-255 RGB) from the local background above which a pixel is ink. */
const INK_DISTANCE = 40;
/** Columns of empty pixels at least this wide (pt) separate the icon from the name in a lockup. */
const MARK_GAP_PT = 5;
/** Connected ink blobs smaller than this many pixels (at 4x) are paper texture or stray specks, not logo. */
const MIN_BLOB_PX = 30;
/*
* Search rectangles in page points (top-left origin). They come from the "Layout" / "Mapping to Voiced
* fields" sections of docs/templates/NN-*.md and were then tightened by looking at the crops, so that
* they hold the placeholder and nothing else:
* 3 Monolith 'CAVEIN' is the first line of a right-aligned stack; the date line starts at y 74, so the rect stops at y 70.
* 5 Monogram the ring (docs: x 387-531, y 58-198); the ring really starts at y 80.
* 6 Serenity the stamp (docs: x 441-531, y 128-218).
* 7 Crimson Grid flame (x 420-452, y 53-99) plus the 2-line name.
* 8 Cobalt swoosh (x 59.5-94, y 59.5-94) plus name and tagline; the grey rule starts at y 112, below the rect.
* 9 Slate Band white mark + name inside the dark band (the band ends at y 103).
* 10 Teal Swoosh mark + name + tagline; the corner swoosh art is far right (x > 350), outside the rect.
* 11 Tangerine the 'Rimberio' text. Reference leaves a 1 px tick at about (62, 95), so the rect stops at y 92.
* 13 Citrus Split mark + 2-line name on the grey column; the halftone chevrons start at x 487, outside the rect.
* kind: 'lockup' = icon + name (split into mark and lockup), 'seal' = one round placeholder (mark = lockup),
* 'text' = a text-only placeholder (no mark).
*/
const SEARCH_RECTS = {
3: { id: "monolith", rect: { x: 480, y: 46, w: 95, h: 24 }, kind: "text", anchor: "right", vAlign: "middle", dark: false },
5: { id: "monogram", rect: { x: 370, y: 60, w: 178, h: 152 }, kind: "seal", anchor: "right", vAlign: "middle", dark: false },
6: { id: "serenity", rect: { x: 430, y: 118, w: 115, h: 108 }, kind: "seal", anchor: "right", vAlign: "middle", dark: false },
7: { id: "crimson-grid", rect: { x: 405, y: 45, w: 140, h: 60 }, kind: "lockup", anchor: "right", vAlign: "bottom", dark: false },
8: { id: "cobalt-stripe", rect: { x: 50, y: 50, w: 215, h: 52 }, kind: "lockup", anchor: "left", vAlign: "middle", dark: false },
9: { id: "slate-band", rect: { x: 50, y: 46, w: 130, h: 50 }, kind: "lockup", anchor: "left", vAlign: "middle", dark: true },
10: { id: "teal-swoosh", rect: { x: 50, y: 52, w: 160, h: 46 }, kind: "lockup", anchor: "left", vAlign: "middle", dark: false },
11: { id: "tangerine-ledger", rect: { x: 50, y: 55, w: 120, h: 37 }, kind: "text", anchor: "left", vAlign: "middle", dark: false },
13: { id: "citrus-split", rect: { x: 325, y: 65, w: 165, h: 55 }, kind: "lockup", anchor: "left", vAlign: "middle", dark: false },
};
const TEMPLATE_IDS = [
"classic", "marble", "highlighter", "monolith", "linea", "monogram", "serenity", "crimson-grid",
"cobalt-stripe", "slate-band", "teal-swoosh", "tangerine-ledger", "purple-pop", "citrus-split",
];
const median = (arr) => {
const s = Uint8Array.from(arr).sort();
return s[s.length >> 1];
};
/** Ink mask of an RGBA crop: pixels far from the median colour of the crop's border. */
function inkMask(img, w, h) {
const border = [[], [], []];
const push = (x, y) => {
const i = (y * w + x) * 4;
for (let c = 0; c < 3; c++) border[c].push(img[i + c]);
};
for (let x = 0; x < w; x++) { push(x, 0); push(x, h - 1); }
for (let y = 1; y < h - 1; y++) { push(0, y); push(w - 1, y); }
const bg = border.map(median);
const mask = new Uint8Array(w * h);
for (let p = 0; p < w * h; p++) {
const i = p * 4;
const d = Math.hypot(img[i] - bg[0], img[i + 1] - bg[1], img[i + 2] - bg[2]);
mask[p] = d > INK_DISTANCE ? 1 : 0;
}
dropSpecks(mask, w, h);
return { mask, bg };
}
/** Clears 8-connected blobs under MIN_BLOB_PX in place (Serenity's paper has dark flecks). */
function dropSpecks(mask, w, h) {
const seen = new Uint8Array(w * h);
const stack = [];
for (let start = 0; start < w * h; start++) {
if (!mask[start] || seen[start]) continue;
const blob = [];
seen[start] = 1;
stack.push(start);
while (stack.length) {
const p = stack.pop();
blob.push(p);
const x = p % w, y = (p - x) / w;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
const q = ny * w + nx;
if (mask[q] && !seen[q]) { seen[q] = 1; stack.push(q); }
}
}
}
if (blob.length < MIN_BLOB_PX) for (const p of blob) mask[p] = 0;
}
}
/** Bounding box (px) and ink count of the mask within columns [x0, x1). */
function inkBox(mask, w, h, x0 = 0, x1 = w) {
let minX = Infinity, minY = Infinity, maxX = -1, maxY = -1, count = 0;
for (let y = 0; y < h; y++) {
for (let x = x0; x < x1; x++) {
if (!mask[y * w + x]) continue;
count++;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
if (count === 0) return null;
return { minX, minY, maxX: maxX + 1, maxY: maxY + 1, count };
}
/** First run of ink columns, ending at the first empty gap of MARK_GAP_PT or more. */
function firstColumnGroup(mask, w, h, box) {
const gap = MARK_GAP_PT * SCALE;
let empty = 0;
for (let x = box.minX; x < box.maxX; x++) {
let any = false;
for (let y = 0; y < h && !any; y++) any = mask[y * w + x] === 1;
empty = any ? 0 : empty + 1;
if (empty >= gap) return x - empty + 1;
}
return null;
}
const r2 = (v) => Math.round(v * 100) / 100;
function toPt(rect, box) {
return {
x: r2(rect.x + box.minX / SCALE),
y: r2(rect.y + box.minY / SCALE),
w: r2((box.maxX - box.minX) / SCALE),
h: r2((box.maxY - box.minY) / SCALE),
};
}
async function main() {
const { createCanvas } = await loadCanvas();
const cropsDir = path.join(OUT_DIR, "crops");
mkdirSync(cropsDir, { recursive: true });
const slots = {};
const rows = [];
for (const [n, spec] of Object.entries(SEARCH_RECTS)) {
const page = await renderPage(Number(n));
const crop = await cropCanvas(page, spec.rect);
const w = crop.width, h = crop.height;
const img = crop.getContext("2d").getImageData(0, 0, w, h).data;
const { mask, bg } = inkMask(img, w, h);
const all = inkBox(mask, w, h);
if (!all) throw new Error(`template ${n} (${spec.id}): no ink in the search rect`);
const touches = all.minX === 0 || all.minY === 0 || all.maxX === w || all.maxY === h;
if (touches) console.warn(`WARNING ${n} ${spec.id}: ink reaches the search rect edge; the rect may cut or include other art`);
const lockupBox = toPt(spec.rect, all);
const lockupDensity = all.count / ((all.maxX - all.minX) * (all.maxY - all.minY));
let markBox = null;
let markDensity = 0;
const boxes = [{ box: all, color: "red" }];
if (spec.kind === "seal") {
markBox = lockupBox;
markDensity = lockupDensity;
} else if (spec.kind === "lockup") {
const split = firstColumnGroup(mask, w, h, all);
if (split === null) throw new Error(`template ${n} (${spec.id}): no gap between mark and name`);
const m = inkBox(mask, w, h, all.minX, split);
markBox = toPt(spec.rect, m);
markDensity = m.count / ((m.maxX - m.minX) * (m.maxY - m.minY));
boxes.push({ box: m, color: "blue" });
}
const rounded = (v) => Math.round(v * 1000) / 1000;
slots[spec.id] = {
page: { w: PAGE.w, h: PAGE.h },
mark: markBox,
lockup: lockupBox,
markDensity: rounded(markDensity),
lockupDensity: rounded(lockupDensity),
anchor: spec.anchor,
vAlign: spec.vAlign,
darkSurface: spec.dark,
};
rows.push({ n, id: spec.id, mark: markBox, lockup: lockupBox, markDensity, lockupDensity, anchor: spec.anchor, dark: spec.dark, bg });
// Crop for review: the search rect with the measured boxes (red = lockup, blue = mark).
const out = createCanvas(w, h);
const ctx = out.getContext("2d");
ctx.drawImage(crop, 0, 0);
ctx.lineWidth = 2;
for (const { box, color } of boxes) {
ctx.strokeStyle = color;
ctx.strokeRect(box.minX - 1, box.minY - 1, box.maxX - box.minX + 2, box.maxY - box.minY + 2);
}
writeFileSync(path.join(cropsDir, `${n}-${spec.id}.png`), out.toBuffer("image/png"));
}
const body = TEMPLATE_IDS.map((id) => {
const s = slots[id];
return ` ${JSON.stringify(id)}: ${s ? JSON.stringify(s) : "null"},`;
}).join("\n");
const ts = `// Generated by \`npm run logo:measure\` from the reference template PDFs (scripts/logo/measure-placeholders.mjs).
// Do not edit by hand: change the search rectangles in the script and run it again.
// Boxes are the ink boxes of each reference logo placeholder, in points from the top-left of the 595.5 x 842.25 page.
// Templates without a placeholder are null here; their opt-in slots are in slots.optin.ts.
import type { TemplateId, TemplateSlot } from "./slots.types";
export const SLOTS: Record<TemplateId, TemplateSlot | null> = {
${body}
};
`;
writeFileSync(path.join(ROOT, "src/pdf/templates/slots.generated.ts"), ts);
const f = (b) => (b ? `${b.x},${b.y} ${b.w}x${b.h}` : "-");
console.log("n id mark lockup markD lockD anchor dark bg");
for (const r of rows) {
console.log(
`${String(r.n).padEnd(2)} ${r.id.padEnd(17)} ${f(r.mark).padEnd(23)} ${f(r.lockup).padEnd(23)} ${r.markDensity.toFixed(3)} ${r.lockupDensity.toFixed(3)} ${r.anchor.padEnd(6)} ${r.dark ? "yes " : "no "} ${r.bg.join(",")}`,
);
}
console.log(`crops: ${cropsDir}`);
}
await main();