diff --git a/package.json b/package.json index 61b196b..c5641d5 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol", "app:build:windows:offline": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol --config '{\"bundle\":{\"windows\":{\"webviewInstallMode\":{\"type\":\"offlineInstaller\"}}}}'", "fonts:verify": "node scripts/fonts/verify.mjs", - "fonts:manifest": "node scripts/fonts/build-manifest.mjs" + "fonts:manifest": "node scripts/fonts/build-manifest.mjs", + "logo:measure": "node scripts/logo/measure-placeholders.mjs", + "logo:sheet": "node scripts/logo/contact-sheet.mjs" }, "dependencies": { "@carbon/icons-react": "^11.89.0", diff --git a/scripts/logo/contact-sheet.mjs b/scripts/logo/contact-sheet.mjs new file mode 100644 index 0000000..5b0350e --- /dev/null +++ b/scripts/logo/contact-sheet.mjs @@ -0,0 +1,254 @@ +// 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 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) })); + + // 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]); + 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 (Canva 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 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)}`, + `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.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(); diff --git a/scripts/logo/lib.mjs b/scripts/logo/lib.mjs new file mode 100644 index 0000000..9280ef2 --- /dev/null +++ b/scripts/logo/lib.mjs @@ -0,0 +1,50 @@ +// Shared helpers for the logo scripts: rasterise a Canva 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", "Modern Neutral Invoice Template"); +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 Canva 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; +} + +/** Canva 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, +}; diff --git a/scripts/logo/measure-placeholders.mjs b/scripts/logo/measure-placeholders.mjs new file mode 100644 index 0000000..0c97325 --- /dev/null +++ b/scripts/logo/measure-placeholders.mjs @@ -0,0 +1,225 @@ +// Measures the logo placeholder of each Canva 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. Canva 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 Canva 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 Canva 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(); diff --git a/src/pdf/engine/logo.slots.test.ts b/src/pdf/engine/logo.slots.test.ts new file mode 100644 index 0000000..bea3f31 --- /dev/null +++ b/src/pdf/engine/logo.slots.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; +import { MIN_LOGO_HEIGHT, computeLogoPlacement, type SlotLogoInput } from "./logo"; +import { SLOTS } from "../templates/slots.generated"; +import { OPTIN_SLOTS, slotFor } from "../templates/slots.optin"; +import { SLOT_PAGE, type Box, type TemplateId, type TemplateSlot } from "../templates/slots.types"; + +const measured = Object.entries(SLOTS).filter((e): e is [TemplateId, TemplateSlot] => e[1] !== null); +const optIn = Object.entries(OPTIN_SLOTS) as [TemplateId, TemplateSlot][]; + +const logo = (over: Partial): SlotLogoInput => ({ + aspect: 1, + density: 0.5, + kind: "mark", + includesName: false, + hasKnockout: true, + ...over, +}); +const Sample_P = logo({ aspect: 4.62, density: 0.437, kind: "wordmark", includesName: true }); + +const inside = (inner: Box, outer: Box, eps = 1e-6) => + inner.x >= outer.x - eps && inner.y >= outer.y - eps && inner.x + inner.w <= outer.x + outer.w + eps && inner.y + inner.h <= outer.y + outer.h + eps; +const closeBox = (a: Box, b: Box, tol = 0.5) => { + for (const k of ["x", "y", "w", "h"] as const) expect(Math.abs(a[k] - b[k]), `${k}: ${a[k]} vs ${b[k]}`).toBeLessThanOrEqual(tol); +}; + +describe("slots.generated", () => { + it("has a measured slot for the nine templates with a Canva placeholder, none for the rest", () => { + expect(measured.map(([id]) => id).sort()).toEqual( + ["citrus-split", "cobalt-stripe", "crimson-grid", "monogram", "monolith", "serenity", "slate-band", "tangerine-ledger", "teal-swoosh"], + ); + for (const id of ["classic", "marble", "highlighter", "linea", "purple-pop"] as const) expect(SLOTS[id]).toBeNull(); + }); + + it("keeps every box on the page, with the mark inside the lockup", () => { + for (const [id, s] of measured) { + const page = { x: 0, y: 0, w: SLOT_PAGE.w, h: SLOT_PAGE.h }; + expect(s.lockup && inside(s.lockup, page), id).toBe(true); + if (s.mark && s.lockup) expect(inside(s.mark, s.lockup, 0.01), id).toBe(true); + } + }); + + it("only Slate Band is a dark surface", () => { + expect(measured.filter(([, s]) => s.darkSurface).map(([id]) => id)).toEqual(["slate-band"]); + }); +}); + +describe("slots.optin", () => { + it("covers the four templates without a placeholder, all opt-in and off by default", () => { + expect(optIn.map(([id]) => id).sort()).toEqual(["highlighter", "linea", "marble", "purple-pop"]); + for (const [id, s] of optIn) { + expect(s.optIn, id).toBe(true); + expect(s.defaultOn, id).toBe(false); + } + }); + + it("slotFor prefers a measured slot and gives Classic none", () => { + expect(slotFor("monogram")).toBe(SLOTS.monogram); + expect(slotFor("marble")).toBe(OPTIN_SLOTS.marble); + expect(slotFor("classic")).toBeNull(); + }); +}); + +describe("computeLogoPlacement identity: the placeholder itself comes back as its own box", () => { + for (const [id, s] of measured) { + it(`${id} lockup`, () => { + const l = s.lockup!; + const p = computeLogoPlacement({ + logo: logo({ aspect: l.w / l.h, density: s.lockupDensity, kind: "wordmark", includesName: true }), + slot: s, + }); + expect(p.slotKind).toBe("lockup"); + closeBox(p.box, l); + }); + if (s.mark) { + it(`${id} mark`, () => { + const m = s.mark!; + const p = computeLogoPlacement({ logo: logo({ aspect: m.w / m.h, density: s.markDensity }), slot: s }); + expect(p.slotKind).toBe("mark"); + closeBox(p.box, m); + }); + } + } +}); + +describe("computeLogoPlacement on every slot", () => { + const all: [TemplateId, TemplateSlot][] = [...measured, ...optIn]; + + it("Test Vendor never exceeds its box and is never heavier than the placeholder ink", () => { + for (const [id, s] of all) { + const p = computeLogoPlacement({ logo: Sample_P, slot: s }); + expect(p.slotKind).toBe("lockup"); + expect(inside(p.box, s.lockup!), id).toBe(true); + if (!s.optIn) { + const ink = p.w * p.h * Sample_P.density; + const placeholderInk = s.lockup!.w * s.lockup!.h * s.lockupDensity; + expect(ink, `${id}: ${ink.toFixed(1)} vs ${placeholderInk.toFixed(1)}`).toBeLessThanOrEqual(placeholderInk * 1.05); + } + } + }); + + it("a square mark, a round emblem and a tall logo stay inside the mark box (the lockup box without one)", () => { + const shapes = [logo({ aspect: 1, density: 0.6 }), logo({ aspect: 1, density: 0.2, kind: "mark" }), logo({ aspect: 0.5, density: 0.5, kind: "tall" })]; + for (const [id, s] of all) { + for (const l of shapes) { + const p = computeLogoPlacement({ logo: l, slot: s }); + expect(inside(p.box, (s.mark ?? s.lockup)!), `${id} aspect ${l.aspect}`).toBe(true); + expect(p.w / p.h).toBeCloseTo(l.aspect, 6); + } + } + }); + + it("falls back to the lockup box for a mark-less slot even when the logo has no name", () => { + const s = SLOTS.monolith!; + expect(s.mark).toBeNull(); + const p = computeLogoPlacement({ logo: logo({ aspect: 1 }), slot: s }); + expect(p.slotKind).toBe("lockup"); + expect(inside(p.box, s.lockup!)).toBe(true); + }); + + it("a denser logo is drawn smaller, at most down to 70 %, and a lighter one is not enlarged", () => { + const s = SLOTS["cobalt-stripe"]!; + const m = s.mark!; + const dense = computeLogoPlacement({ logo: logo({ aspect: m.w / m.h, density: 1 }), slot: s }); + expect(dense.h / m.h).toBeCloseTo(Math.max(0.7, Math.sqrt(s.markDensity)), 6); + const heavy = computeLogoPlacement({ logo: logo({ aspect: m.w / m.h, density: 50 }), slot: s }); + expect(heavy.h / m.h).toBeCloseTo(0.7, 6); + const light = computeLogoPlacement({ logo: logo({ aspect: m.w / m.h, density: 0.01 }), slot: s }); + expect(light.h).toBeCloseTo(m.h, 6); + }); + + it("aligns by the slot's anchor and vAlign", () => { + const s = SLOTS["crimson-grid"]!; // right, bottom + const m = s.mark!; + const p = computeLogoPlacement({ logo: logo({ aspect: 0.5, density: 0.1 }), slot: s }); + expect(p.x + p.w).toBeCloseTo(m.x + m.w, 6); + expect(p.y + p.h).toBeCloseTo(m.y + m.h, 6); + const left = computeLogoPlacement({ logo: logo({ aspect: 0.5, density: 0.1 }), slot: { ...s, anchor: "left", vAlign: "top" } }); + expect(left.x).toBeCloseTo(m.x, 6); + expect(left.y).toBeCloseTo(m.y, 6); + const mid = computeLogoPlacement({ logo: logo({ aspect: 0.5, density: 0.1 }), slot: { ...s, anchor: "center", vAlign: "middle" } }); + expect(mid.x + mid.w / 2).toBeCloseTo(m.x + m.w / 2, 6); + expect(mid.y + mid.h / 2).toBeCloseTo(m.y + m.h / 2, 6); + }); +}); + +describe("computeLogoPlacement surface", () => { + const slate = SLOTS["slate-band"]!; + + it("uses the knockout on a dark surface, a white chip without one, and the print image elsewhere", () => { + expect(computeLogoPlacement({ logo: logo({ hasKnockout: true }), slot: slate }).variant).toBe("knockout"); + const chip = computeLogoPlacement({ logo: logo({ hasKnockout: false }), slot: slate }); + expect(chip.variant).toBe("chip"); + expect(chip.chip).toEqual({ x: chip.x - 4, y: chip.y - 4, w: chip.w + 8, h: chip.h + 8 }); + for (const [id, s] of measured) { + if (id === "slate-band") continue; + expect(computeLogoPlacement({ logo: logo({ hasKnockout: false }), slot: s }).variant, id).toBe("print"); + } + expect(computeLogoPlacement({ logo: logo({}), slot: slate }).chip).toBeUndefined(); + }); +}); + +describe("computeLogoPlacement legibility floor", () => { + const slot = (h: number): TemplateSlot => ({ + page: { ...SLOT_PAGE }, + mark: { x: 50, y: 50, w: 100, h }, + lockup: { x: 50, y: 50, w: 100, h }, + markDensity: 0.25, + lockupDensity: 0.25, + anchor: "left", + vAlign: "middle", + darkSurface: false, + }); + + it("raises a logo that the weight factor made too short to 14 pt", () => { + // contain gives 16 pt tall; density 1 against 0.25 shrinks it to 11.2 pt. + const p = computeLogoPlacement({ logo: logo({ aspect: 1, density: 1 }), slot: slot(16) }); + expect(p.h).toBe(MIN_LOGO_HEIGHT); + expect(p.w).toBe(MIN_LOGO_HEIGHT); + }); + + it("never grows past the box: a box under 14 pt stays the limit", () => { + const p = computeLogoPlacement({ logo: logo({ aspect: 4, density: 0.25 }), slot: slot(9) }); + expect(p.h).toBeCloseTo(9, 6); + expect(p.w).toBeCloseTo(36, 6); + }); + + it("lets the width follow the floor and clamps it to the box", () => { + const p = computeLogoPlacement({ logo: logo({ aspect: 10, density: 0.25 }), slot: slot(40) }); + // contain: 100 x 10 (width-limited), floor wants 14 x 140, the box width wins. + expect(p.w).toBeCloseTo(100, 6); + expect(p.h).toBeCloseTo(10, 6); + }); +}); + +describe("computeLogoPlacement page scale and available room", () => { + const letter = 612 / 595.28; + + it("scales the box about its anchor: a right-aligned slot keeps its right edge", () => { + const s = SLOTS.monogram!; + const m = s.mark!; + const p = computeLogoPlacement({ logo: logo({ aspect: m.w / m.h, density: s.markDensity }), slot: s, scale: letter }); + expect(p.w).toBeCloseTo(m.w * letter, 6); + expect(p.h).toBeCloseTo(m.h * letter, 6); + expect(p.x + p.w).toBeCloseTo(m.x + m.w, 6); + }); + + it("shrinks to the room the layout has", () => { + const s = SLOTS["teal-swoosh"]!; + const p = computeLogoPlacement({ logo: Sample_P, slot: s, available: { w: 80, h: 100 } }); + expect(p.w).toBeLessThanOrEqual(80 + 1e-9); + expect(p.x).toBeCloseTo(s.lockup!.x, 6); + }); +}); diff --git a/src/pdf/engine/logo.test.ts b/src/pdf/engine/logo.test.ts index c831b1a..da5fb46 100644 --- a/src/pdf/engine/logo.test.ts +++ b/src/pdf/engine/logo.test.ts @@ -1,49 +1,49 @@ import { describe, expect, it } from "vitest"; -import { computeLogoPlacement } from "./logo"; +import { computeClassicLogoPlacement } from "./logo"; const BOX = { w: 170, h: 54 }; const base = { box: BOX, availableWidth: 299, minTextWidth: 160 }; -describe("computeLogoPlacement", () => { +describe("computeClassicLogoPlacement", () => { it("the Test Vendor trimmed 4.6:1 logo is width-limited and stacks above the text", () => { - const p = computeLogoPlacement({ ...base, aspect: 4.6 }); + const p = computeClassicLogoPlacement({ ...base, aspect: 4.6 }); expect(p.w).toBeCloseTo(170, 6); expect(p.h).toBeCloseTo(170 / 4.6, 6); expect(p.stacked).toBe(true); }); it("an untrimmed 3:1 logo is height-limited (54 pt) and still stacks", () => { - const p = computeLogoPlacement({ ...base, aspect: 3 }); + const p = computeClassicLogoPlacement({ ...base, aspect: 3 }); expect(p.h).toBe(54); expect(p.w).toBeCloseTo(162, 6); expect(p.stacked).toBe(true); }); it("a square logo is 54 x 54 and sits beside the text", () => { - const p = computeLogoPlacement({ ...base, aspect: 1 }); + const p = computeClassicLogoPlacement({ ...base, aspect: 1 }); expect(p).toEqual({ w: 54, h: 54, stacked: false }); }); it("stacks exactly when the text beside the logo would drop under the minimum", () => { // 299 - 54 - 14 = 231 beside a square; a 2:1 logo (108 wide) leaves 177; a 2.5:1 one (135) leaves 150. - expect(computeLogoPlacement({ ...base, aspect: 2 }).stacked).toBe(false); - expect(computeLogoPlacement({ ...base, aspect: 2.5 }).stacked).toBe(true); + expect(computeClassicLogoPlacement({ ...base, aspect: 2 }).stacked).toBe(false); + expect(computeClassicLogoPlacement({ ...base, aspect: 2.5 }).stacked).toBe(true); // The boundary: leaves exactly 160. const w = 299 - 14 - 160; - expect(computeLogoPlacement({ ...base, aspect: w / 54 }).stacked).toBe(false); - expect(computeLogoPlacement({ ...base, aspect: (w + 0.01) / 54 }).stacked).toBe(true); + expect(computeClassicLogoPlacement({ ...base, aspect: w / 54 }).stacked).toBe(false); + expect(computeClassicLogoPlacement({ ...base, aspect: (w + 0.01) / 54 }).stacked).toBe(true); }); it("gives the logo more room on a wider column (Letter)", () => { - const a4 = computeLogoPlacement({ ...base, aspect: 2.4, availableWidth: 299.28 }); - const letter = computeLogoPlacement({ ...base, aspect: 2.4, availableWidth: 316 }); + const a4 = computeClassicLogoPlacement({ ...base, aspect: 2.4, availableWidth: 299.28 }); + const letter = computeClassicLogoPlacement({ ...base, aspect: 2.4, availableWidth: 316 }); expect(a4.stacked).toBe(true); expect(letter.stacked).toBe(false); }); it("never exceeds the box, whatever the aspect", () => { for (const aspect of [0.2, 0.5, 1, 1.5, 3, 4.6, 10, 40]) { - const p = computeLogoPlacement({ ...base, aspect }); + const p = computeClassicLogoPlacement({ ...base, aspect }); expect(p.w).toBeLessThanOrEqual(170 + 1e-9); expect(p.h).toBeLessThanOrEqual(54 + 1e-9); expect(p.w / p.h).toBeCloseTo(aspect, 6); @@ -51,7 +51,7 @@ describe("computeLogoPlacement", () => { }); it("falls back to the box aspect for a bad aspect", () => { - const p = computeLogoPlacement({ ...base, aspect: Number.NaN }); + const p = computeClassicLogoPlacement({ ...base, aspect: Number.NaN }); expect(p.w).toBeCloseTo(170, 6); expect(p.h).toBeCloseTo(54, 6); }); diff --git a/src/pdf/engine/logo.ts b/src/pdf/engine/logo.ts index d31627f..f5c7562 100644 --- a/src/pdf/engine/logo.ts +++ b/src/pdf/engine/logo.ts @@ -1,3 +1,5 @@ +import type { Box, TemplateSlot } from "../templates/slots.types"; + export interface LogoPlacementInput { /** Width / height of the image (use the trimmed ink aspect once the logo pipeline provides it). */ aspect: number; @@ -24,15 +26,117 @@ export const LOGO_GAP = 14; * Fits the logo inside `box` by its aspect (contain), then decides whether it still leaves the text * `minTextWidth` beside it. Same maths as the Phase 0 planLogo. */ -export function computeLogoPlacement({ aspect, box, availableWidth, minTextWidth, gap = LOGO_GAP }: LogoPlacementInput): LogoPlacement { +export function computeClassicLogoPlacement({ aspect, box, availableWidth, minTextWidth, gap = LOGO_GAP }: LogoPlacementInput): LogoPlacement { const a = Number.isFinite(aspect) && aspect > 0 ? aspect : box.w / box.h; const h = Math.min(box.h, box.w / a); const w = h * a; return { w, h, stacked: availableWidth - w - gap < minTextWidth }; } -/* - * Seam for Canva templates: slots.generated.ts (measured placeholder boxes, see - * scripts/logo/measure-placeholders.mjs) will supply a template's `logo.box` and may refine the fit. - * Until it exists, every template declares its box by hand in its TemplateDefinition. +/** Smallest height a logo is drawn at when its box allows it, pt. */ +export const MIN_LOGO_HEIGHT = 14; +/** White padding of the chip behind a logo that has no knockout variant, pt. */ +export const CHIP_PADDING = 4; +/** The weight factor never shrinks a logo below this share of its contain-fit size. */ +const MIN_WEIGHT_FACTOR = 0.7; + +/** Ratio of the page's content width to the Canva page's (1 for the page the slot was measured on). */ +export type PageScale = number; + +export interface SlotLogoInput { + /** Width / height of the logo's ink box. */ + aspect: number; + /** Share of the ink box that is ink, 0 to 1. */ + density: number; + kind: "wordmark" | "mark" | "tall"; + /** The logo already carries the business name, so it replaces the template's whole lockup. */ + includesName: boolean; + /** A white-ink variant exists, for dark surfaces. */ + hasKnockout: boolean; +} + +export interface SlotLogoPlacementInput { + logo: SlotLogoInput; + slot: TemplateSlot; + scale?: PageScale; + /** Room the layout can actually give the logo; the target box shrinks to it, keeping its anchor. */ + available?: { w: number; h: number }; +} + +export interface SlotLogoPlacement { + /** Where the logo image goes, page points from the top-left. */ + box: Box; + x: number; + y: number; + w: number; + h: number; + variant: "print" | "knockout" | "chip"; + /** Which placeholder box the logo was fitted to. */ + slotKind: "lockup" | "mark"; + /** For the chip variant: the white rounded rectangle behind the logo (the logo box plus CHIP_PADDING). */ + chip?: Box; + /** Only Classic's own stack-or-beside decision sets this; slot placement never stacks. */ + stacked?: boolean; +} + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); + +/** A w x h box inside `target`, aligned the way the placeholder was. */ +function align(target: Box, w: number, h: number, slot: TemplateSlot): Box { + const x = slot.anchor === "left" ? target.x : slot.anchor === "right" ? target.x + target.w - w : target.x + (target.w - w) / 2; + const y = slot.vAlign === "top" ? target.y : slot.vAlign === "bottom" ? target.y + target.h - h : target.y + (target.h - h) / 2; + return { x, y, w, h }; +} + +/** + * Places the user's logo where a template's logo placeholder is. Contain-fit in the measured box, then + * a shrink-only weight factor so a denser logo draws smaller and matches the placeholder's visual + * weight. A wordmark (includesName) replaces the whole lockup; a mark replaces the icon only. The + * legibility floor never pushes past the box: a box under MIN_LOGO_HEIGHT stays the limit. */ +export function computeLogoPlacement({ logo, slot, scale = 1, available }: SlotLogoPlacementInput): SlotLogoPlacement { + const wantLockup = logo.includesName || !slot.mark; + const target = wantLockup ? (slot.lockup ?? slot.mark) : slot.mark; + if (!target) throw new Error("slot has neither a mark nor a lockup box"); + const slotKind = wantLockup ? "lockup" : "mark"; + const placeholderDensity = wantLockup ? slot.lockupDensity : slot.markDensity; + + // Scale the box about its anchor so a right-aligned slot keeps its right edge. + let bw = target.w * scale; + let bh = target.h * scale; + if (available) { + bw = Math.min(bw, available.w); + bh = Math.min(bh, available.h); + } + const box = align(target, bw, bh, slot); + + const aspect = Number.isFinite(logo.aspect) && logo.aspect > 0 ? logo.aspect : bw / bh; + let h = Math.min(bh, bw / aspect); + let w = h * aspect; + + const valid = logo.density > 0 && placeholderDensity > 0; + const f = valid ? clamp(Math.sqrt(placeholderDensity / logo.density), MIN_WEIGHT_FACTOR, 1) : 1; + w *= f; + h *= f; + + if (h < MIN_LOGO_HEIGHT) { + h = MIN_LOGO_HEIGHT; + w = h * aspect; + if (w > bw) { + w = bw; + h = w / aspect; + } + if (h > bh) { + h = bh; + w = h * aspect; + } + } + + const placed = align(box, w, h, slot); + const variant = slot.darkSurface ? (logo.hasKnockout ? "knockout" : "chip") : "print"; + const out: SlotLogoPlacement = { box: placed, ...placed, variant, slotKind }; + if (variant === "chip") { + out.chip = { x: placed.x - CHIP_PADDING, y: placed.y - CHIP_PADDING, w: placed.w + 2 * CHIP_PADDING, h: placed.h + 2 * CHIP_PADDING }; + } + return out; +} diff --git a/src/pdf/render/invoice.ts b/src/pdf/render/invoice.ts index cea794c..e0d1557 100644 --- a/src/pdf/render/invoice.ts +++ b/src/pdf/render/invoice.ts @@ -1,5 +1,5 @@ import { createElement } from "react"; -import { computeLogoPlacement } from "../engine/logo"; +import { computeClassicLogoPlacement } from "../engine/logo"; import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry"; import type { AuditIssue } from "../engine/audit"; import type { RenderModel } from "../model/build"; @@ -47,7 +47,7 @@ export async function renderInvoicePdf({ model, prefs }: RenderInvoiceInput, byt let logo: PlacedLogo | null = null; if (model.logo && template.logo.defaultOn) { - const placed = computeLogoPlacement({ + const placed = computeClassicLogoPlacement({ aspect: model.logo.width / model.logo.height, box: template.logo.box, availableWidth: template.logo.slotWidth(frame), diff --git a/src/pdf/templates/slots.generated.ts b/src/pdf/templates/slots.generated.ts new file mode 100644 index 0000000..fa5a854 --- /dev/null +++ b/src/pdf/templates/slots.generated.ts @@ -0,0 +1,22 @@ +// Generated by `npm run logo:measure` from the Canva 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 Canva 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 = { + "classic": null, + "marble": null, + "highlighter": null, + "monolith": {"page":{"w":595.5,"h":842.25},"mark":null,"lockup":{"x":513.25,"y":54.75,"w":46.75,"h":9},"markDensity":0,"lockupDensity":0.579,"anchor":"right","vAlign":"middle","darkSurface":false}, + "linea": null, + "monogram": {"page":{"w":595.5,"h":842.25},"mark":{"x":387.75,"y":81.25,"w":142.75,"h":116.5},"lockup":{"x":387.75,"y":81.25,"w":142.75,"h":116.5},"markDensity":0.142,"lockupDensity":0.142,"anchor":"right","vAlign":"middle","darkSurface":false}, + "serenity": {"page":{"w":595.5,"h":842.25},"mark":{"x":444.5,"y":131.75,"w":82,"h":81.75},"lockup":{"x":444.5,"y":131.75,"w":82,"h":81.75},"markDensity":0.069,"lockupDensity":0.069,"anchor":"right","vAlign":"middle","darkSurface":false}, + "crimson-grid": {"page":{"w":595.5,"h":842.25},"mark":{"x":420.5,"y":53,"w":31.25,"h":45.75},"lockup":{"x":420.5,"y":53,"w":113.25,"h":45.75},"markDensity":0.466,"lockupDensity":0.261,"anchor":"right","vAlign":"bottom","darkSurface":false}, + "cobalt-stripe": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":59.5,"w":34.25,"h":34.25},"lockup":{"x":59.5,"y":59.5,"w":181.5,"h":34.25},"markDensity":0.479,"lockupDensity":0.287,"anchor":"left","vAlign":"middle","darkSurface":false}, + "slate-band": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":55.75,"w":25,"h":31.25},"lockup":{"x":59.5,"y":55.75,"w":99.25,"h":31.25},"markDensity":0.492,"lockupDensity":0.181,"anchor":"left","vAlign":"middle","darkSurface":true}, + "teal-swoosh": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":59.5,"w":41,"h":27},"lockup":{"x":59.5,"y":59.5,"w":125.75,"h":29.25},"markDensity":0.445,"lockupDensity":0.233,"anchor":"left","vAlign":"middle","darkSurface":false}, + "tangerine-ledger": {"page":{"w":595.5,"h":842.25},"mark":null,"lockup":{"x":60.5,"y":69.75,"w":82.5,"h":14.75},"markDensity":0,"lockupDensity":0.486,"anchor":"left","vAlign":"middle","darkSurface":false}, + "purple-pop": null, + "citrus-split": {"page":{"w":595.5,"h":842.25},"mark":{"x":337.5,"y":74.5,"w":34,"h":32.75},"lockup":{"x":337.5,"y":73.25,"w":144.5,"h":34},"markDensity":0.543,"lockupDensity":0.259,"anchor":"left","vAlign":"middle","darkSurface":false}, +}; diff --git a/src/pdf/templates/slots.optin.ts b/src/pdf/templates/slots.optin.ts new file mode 100644 index 0000000..eda14e5 --- /dev/null +++ b/src/pdf/templates/slots.optin.ts @@ -0,0 +1,44 @@ +import { SLOT_PAGE, type Box, type TemplateId, type TemplateSlot } from "./slots.types"; +import { SLOTS } from "./slots.generated"; + +/* + * Canva ships no logo placeholder for these four templates, so there is nothing to measure. Each slot + * is a free area named in docs/templates/NN-*.md and is an opt-in position, off by default (the plan + * keeps Linea logo-free so it still matches format.pdf). Hand-written; not produced by logo:measure. + * + * With no placeholder ink to match, the density is a typical logo's (Test Vendor is 0.437), so the weight + * factor in computeLogoPlacement only shrinks logos that are denser than that. + */ +const TYPICAL_DENSITY = 0.4; + +function slot(lockup: Box, anchor: TemplateSlot["anchor"], vAlign: TemplateSlot["vAlign"], mark: Box = lockup): TemplateSlot { + return { + page: { ...SLOT_PAGE }, + mark, + lockup, + markDensity: TYPICAL_DENSITY, + lockupDensity: TYPICAL_DENSITY, + anchor, + vAlign, + darkSurface: false, + optIn: true, + defaultOn: false, + }; +} + +export const OPTIN_SLOTS: Partial> = { + // 01-marble: header-left is empty (x 38.5-300), bottom-aligned with the title baseline; 150 x 50, bottom edge y 136. + marble: slot({ x: 38.5, y: 86, w: 150, h: 50 }, "left", "bottom"), + // 02-highlighter: empty top right (x 352-536, y 50-114); 150 x 50 right-aligned at x 536, next to the vendor name. + highlighter: slot({ x: 386, y: 50, w: 150, h: 50 }, "right", "top"), + // 04-linea: optional band above the title (x 59.5, y 56-120, at most 150 x 48), sitting on the band's bottom edge. + linea: slot({ x: 59.5, y: 72, w: 150, h: 48 }, "left", "bottom"), + // 12-purple-pop: no slot; at most 34 tall at x 59.5, centred on the vendor name (baseline 98.5). A mark pushes the + // name right (40 wide); a wordmark replaces the name (the area up to the INVOICE NO pill at x 312.8). + "purple-pop": slot({ x: 59.5, y: 71, w: 200, h: 34 }, "left", "middle", { x: 59.5, y: 71, w: 40, h: 34 }), +}; + +/** The template's slot: a measured Canva placeholder, or the hand-written opt-in area, or null (Classic). */ +export function slotFor(id: TemplateId): TemplateSlot | null { + return SLOTS[id] ?? OPTIN_SLOTS[id] ?? null; +} diff --git a/src/pdf/templates/slots.types.ts b/src/pdf/templates/slots.types.ts new file mode 100644 index 0000000..f242664 --- /dev/null +++ b/src/pdf/templates/slots.types.ts @@ -0,0 +1,49 @@ +/** Canva page the slot boxes are measured on (pt, top-left origin). */ +export const SLOT_PAGE = { w: 595.5, h: 842.25 } as const; + +export type TemplateId = + | "classic" + | "marble" + | "highlighter" + | "monolith" + | "linea" + | "monogram" + | "serenity" + | "crimson-grid" + | "cobalt-stripe" + | "slate-band" + | "teal-swoosh" + | "tangerine-ledger" + | "purple-pop" + | "citrus-split"; + +/** A rectangle in page points from the top-left corner. */ +export interface Box { + x: number; + y: number; + w: number; + h: number; +} + +export type SlotAnchor = "left" | "right" | "center"; +export type SlotVAlign = "top" | "middle" | "bottom"; + +/** Where a template's logo goes: the ink boxes of its (Canva) placeholder. */ +export interface TemplateSlot { + page: { w: number; h: number }; + /** The icon alone, for placeholders that are icon + name. Seals use the whole seal; text-only placeholders have none. */ + mark: Box | null; + /** The whole placeholder: icon + brand name, or the text for text-only placeholders. */ + lockup: Box | null; + /** Ink pixels / box area of `mark` and `lockup`. */ + markDensity: number; + lockupDensity: number; + anchor: SlotAnchor; + vAlign: SlotVAlign; + /** The slot sits on a dark fill: use the knockout logo, or a white chip behind a logo without alpha. */ + darkSurface: boolean; + /** The template has no placeholder; the position is offered as an option. */ + optIn?: boolean; + /** Whether an opt-in logo is shown unless the user turns it off. */ + defaultOn?: boolean; +}