Linea (the vendor's original format.pdf design), Monogram and Serenity are rebuilt from the Canva PDFs as one parametric family on the engine, with the user's logo in the measured slot, a home for every GST field and A4/Letter support. Adds static Montserrat, Open Sans and Poppins fonts and a generated Ms Madi outline for Serenity's vertical word. Text-only logo placeholders (Monolith, Tangerine) use a doc-derived allowance box. A Carbon TemplatePicker with committed thumbnails opens from the page setup controls.
251 lines
11 KiB
JavaScript
251 lines
11 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 },
|
|
};
|
|
|
|
/*
|
|
* 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 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();
|