// 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 }, }; /* * Allowances for text-only placeholders: the placeholder's own box (46.8 x 9 pt, 82.5 x 14.8 pt) would * make a real logo unreadable, so the target box comes from the template docs and keeps the text's * anchor edge. * monolith docs/templates/03-monolith.md "Mapping to Voiced fields": 'CAVEIN' becomes the vendor logo, * right-aligned, at most 28 pt tall so the 3-line stack keeps its 21 pt pitch (a 3:1 PNG is * 84 x 28); up to 160 wide, ending at the same right edge (x 560); top at the text's cap top. * tangerine-ledger docs/templates/11-tangerine-ledger.md "Mapping": 'Rimberio' becomes the vendor logo, maxHeight 36 * at x 59.5, y about 62; up to 220 wide, left edge kept. */ const ALLOWANCES = { monolith: { w: 160, h: 28, top: 54.75, vAlign: "top" }, "tangerine-ledger": { w: 220, h: 36, top: 62, vAlign: "middle" }, }; const r2 = (v) => Math.round(v * 100) / 100; /** The allowance box for a text-only slot: its width and height from the docs, anchored where the text is. */ function allowanceBox(id, lockup, anchor) { const a = ALLOWANCES[id]; const x = anchor === "right" ? lockup.x + lockup.w - a.w : lockup.x; return { x: r2(x), y: a.top, w: a.w, h: a.h }; } 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; } 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; const textOnly = spec.kind === "text"; slots[spec.id] = { page: { w: PAGE.w, h: PAGE.h }, mark: markBox, lockup: lockupBox, markDensity: rounded(markDensity), lockupDensity: rounded(lockupDensity), anchor: spec.anchor, vAlign: textOnly ? ALLOWANCES[spec.id].vAlign : spec.vAlign, darkSurface: spec.dark, textOnly, allowance: textOnly ? allowanceBox(spec.id, lockupBox, spec.anchor) : lockupBox, }; 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 = { ${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();