scripts/logo/measure-placeholders rasterises the Canva 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 Arun P logo and three test logos.
226 lines
10 KiB
JavaScript
226 lines
10 KiB
JavaScript
// 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<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();
|