Files
Voiced/scripts/templates/compare.mjs
T
xavierk a2152ae3c6 Add the Marble and Highlighter templates, completing the 13 Canva templates
Marble uses a flat-to-gradient band in place of the marble photo and bundles Lustria (OFL).
Both logo slots are opt-in. Quirk 13 documents a fontkit glyph-cache issue that the
tests work around with warm-up renders; the engine fix follows separately.
2026-10-04 12:47:05 +05:30

116 lines
8.1 KiB
JavaScript

// Side-by-side review of the Canva-based templates: the Canva original (left) against our render (right), plus
// the multi-page, Letter, long-text and logo-variant samples. Run: npm run templates:compare [-- monolith cobalt-stripe]
// Option --rows=N overrides the sample's item count (fewer rows keep every gap at its nominal size). Output: /tmp/invoice-c6b/ (override with --out=DIR). With --numbers it also prints text baselines (Canva vs ours) for tuning.
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import { CANVA_DIR, loadCanvas, loadRenderer, rasterize, textItems } from "./lib.mjs";
const argv = process.argv.slice(2);
const OUT = argv.find((a) => a.startsWith("--out="))?.slice(6) ?? "/tmp/invoice-c6b";
const SCALE = 1.6;
const PDF_NO = { 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 };
const ROWS = { linea: 7, "crimson-grid": 3, "tangerine-ledger": 3, "slate-band": 5, "teal-swoosh": 5, "purple-pop": 3, "citrus-split": 3, monolith: 3, "cobalt-stripe": 8, marble: 7, highlighter: 3 };
const DARK = ["slate-band"];
const args = process.argv.slice(2);
const numbers = args.includes("--numbers");
const ids = args.filter((a) => !a.startsWith("--"));
const targets = ids.length ? ids : ["monolith", "cobalt-stripe", "marble", "highlighter"];
// Templates with no Canva logo placeholder: the logo shows only when the user turns it on.
const OPT_IN = ["linea", "purple-pop", "marble", "highlighter"];
// Of those, the ones whose compare images show the opt-in logo (Linea stays logo-free, like format.pdf).
const LOGO_ON = ["purple-pop"];
const LOGO_SAMPLES_ON = ["purple-pop", "marble", "highlighter"];
const LONG = "Unit 4, Third Floor, The Grand Heritage Business Centre, Plot 1187-B, Old Airport Road Extension, Opposite the Municipal Corporation Ward Office, Domlur Second Stage, Bengaluru Urban District, Karnataka State, India - 560071; Registered office: Level 12, Tower C, Global Technology Park, Outer Ring Road, Marathahalli, Bengaluru - 560103";
const TOKEN = "Supercalifragilisticexpialidocious-Pneumonoultramicroscopic-1234567890";
async function main() {
mkdirSync(OUT, { recursive: true });
const { createCanvas } = await loadCanvas();
const r = await loadRenderer();
r.ensureSetup();
const side = async (left, right, file) => {
const gap = 12;
const c = createCanvas(left.width + right.width + gap, Math.max(left.height, right.height));
const ctx = c.getContext("2d");
ctx.fillStyle = "#888888";
ctx.fillRect(0, 0, c.width, c.height);
ctx.drawImage(left, 0, 0);
ctx.drawImage(right, left.width + gap, 0);
writeFileSync(path.join(OUT, file), c.toBuffer("image/png"));
};
for (const id of targets) {
const no = PDF_NO[id];
const canvaBytes = readFileSync(path.join(CANVA_DIR, `${no}.pdf`));
const canva = await rasterize(canvaBytes, 1, SCALE);
const rows = Number(argv.find((a) => a.startsWith("--rows="))?.slice(7)) || (ROWS[id] ?? 5);
const NAMES = ["Brand consultation", "Logo design", "Website design", "Social media templates", "Brand photography", "Brand guide", "Social media templates", "Name card"];
// Short one-line descriptions like the Canva sample, so positions can be compared row by row.
const items = (hsn) => NAMES.slice(0, rows).map((description, i) => ({ id: i + 1, description, mode: "rate", rate: 100, unit: "hour", quantity: 1, amount: 0, sortOrder: i, ...(hsn ? { hsnSac: "998397" } : {}) }));
const sample = (over = {}) => ({ name: `s-${id}`, items: items(over.registered !== false), signature: true, invoice: { poNumber: "", notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." }, ...over });
const render = async (spec, size = "A4") => {
// Templates with no placeholder turn the opt-in on for their logo samples (Linea's compare stays logo-free, like format.pdf;
// Marble and Highlighter compare logo-free against the Canva page and show the logo in the -logo-* samples).
const on = spec.logo && spec.logo !== "none" && (LOGO_ON.includes(id) || (LOGO_SAMPLES_ON.includes(id) && spec.logoSample));
const prefs = r.prefsFor(size, { templateId: id, ...(on ? { logo: { show: true } } : {}) });
return r.renderFixture(spec, prefs);
};
const withLogo = await render(sample({ logo: "real" }));
await side(canva, await rasterize(withLogo.result.bytes, 1, SCALE), `compare-${id}.png`);
const noLogo = await render(sample({ registered: false, logo: "none" }));
await side(canva, await rasterize(noLogo.result.bytes, 1, SCALE), `compare-${id}-nologo.png`);
if (numbers) {
// Tracked text comes out of pdf.js one glyph run at a time: join runs on the same baseline that touch.
const merge = (items) => {
const out = [];
for (const it of items) {
const prev = out[out.length - 1];
if (prev && Math.abs(prev.baseline - it.baseline) < 0.3 && it.x - (prev.x + prev.w) < 4 && it.x >= prev.x) {
prev.str += it.str.length > 1 || prev.str.length > 1 ? ` ${it.str}` : it.str;
prev.w = Math.round((it.x + it.w - prev.x) * 10) / 10;
} else out.push({ ...it });
}
return out;
};
// The sample without GST data (noLogo), like the Canva page: one row per item, no sub-lines.
const a = merge(await textItems(canvaBytes));
const b = merge(await textItems(noLogo.result.bytes));
console.log(`--- ${id}: Canva (${a.length}) vs ours (${b.length}); baseline y, x`);
for (const it of a) console.log(` canva ${String(it.baseline).padStart(6)} ${String(it.x).padStart(6)} w${String(it.w).padStart(6)} s${it.size} ${it.str.slice(0, 40)}`);
for (const it of b) console.log(` ours ${String(it.baseline).padStart(6)} ${String(it.x).padStart(6)} w${String(it.w).padStart(6)} s${it.size} ${it.str.slice(0, 40)}`);
}
if (OPT_IN.includes(id)) {
// The opt-in logo (off by default: Linea keeps matching format.pdf, Purple Pop has no placeholder).
const shown = await r.renderFixture(sample({ logo: "real" }), r.prefsFor("A4", { templateId: id, logo: { show: true } }));
writeFileSync(path.join(OUT, `${id}-A4-logo-optin.png`), (await rasterize(shown.result.bytes, 1, SCALE)).toBuffer("image/png"));
}
// Dark-surface templates: the white knockout variant (a logo with alpha) next to the chip fallback (no alpha) above.
const logoKinds = DARK.includes(id) ? ["square", "wide", "knockout"] : ["square", "wide"];
for (const logo of logoKinds) {
const x = await render(sample({ logo, logoSample: true }));
writeFileSync(path.join(OUT, `${id}-A4-logo-${logo}.png`), (await rasterize(x.result.bytes, 1, SCALE)).toBuffer("image/png"));
}
const sixty = await render({ name: "s60", items: 60, signature: true });
for (let p = 1; p <= Math.min(2, sixty.result.pages); p++) writeFileSync(path.join(OUT, `${id}-A4-60rows-p${p}.png`), (await rasterize(sixty.result.bytes, p, SCALE)).toBuffer("image/png"));
const letter = await render(sample({ logo: "real", logoSample: true }), "LETTER");
writeFileSync(path.join(OUT, `${id}-Letter-tax.png`), (await rasterize(letter.result.bytes, 1, SCALE)).toBuffer("image/png"));
const long = await render({
name: "long",
items: 4,
signature: true,
logo: "real",
logoSample: true,
vendor: { vendorAddress: LONG, vendorEmail: `${TOKEN}@example-studios-india.co.in`, vendorName: TOKEN },
invoice: { clientName: TOKEN, clientAddress: LONG, notes: `Note: ${TOKEN} ${LONG}` },
});
writeFileSync(path.join(OUT, `${id}-A4-longtext.png`), (await rasterize(long.result.bytes, 1, SCALE)).toBuffer("image/png"));
console.log(`${id}: pages ${withLogo.result.pages}/${sixty.result.pages}/${letter.result.pages}/${long.result.pages}; audit issues`, [withLogo, noLogo, sixty, letter, long].map((x) => x.result.issues.length).join("/"));
for (const x of [withLogo, noLogo, sixty, letter, long]) for (const i of x.result.issues) console.log(" ", i.severity, i.code, `p${i.page}`, i.message.slice(0, 160));
}
}
await main();