Add the Neutral template family, template picker and text-only logo allowances

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.
This commit is contained in:
2026-10-04 07:27:12 +05:30
parent 490c5b9009
commit 3f7c12b7dd
65 changed files with 3278 additions and 110 deletions
+88
View File
@@ -0,0 +1,88 @@
// Side-by-side review of the Neutral templates: the Canva original (left) against our render (right), plus
// the multi-page, Letter and long-text samples. Run: npm run templates:compare [-- linea monogram serenity]
// Output: /tmp/invoice-c3/. 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 OUT = "/tmp/invoice-c3";
const SCALE = 1.6;
const PDF_NO = { marble: 1, highlighter: 2, monolith: 3, linea: 4, monogram: 5, serenity: 6 };
const args = process.argv.slice(2);
const numbers = args.includes("--numbers");
const ids = args.filter((a) => !a.startsWith("--"));
const targets = ids.length ? ids : ["linea", "monogram", "serenity"];
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 = id === "linea" ? 7 : 5;
const NAMES = ["Brand consultation", "Logo design", "Website design", "Social media templates", "Brand photography", "Brand guide", "Social media templates"];
// 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") => {
const prefs = r.prefsFor(size, { templateId: id });
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) {
const a = await textItems(canvaBytes);
const b = await textItems(withLogo.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 (id === "linea") {
// The opt-in logo band (off by default so Linea keeps matching format.pdf).
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"));
}
for (const logo of ["square", "wide"]) {
const x = await render(sample({ logo }));
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" }), "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",
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();
+4
View File
@@ -0,0 +1,4 @@
// Bundled by scripts/templates/lib.mjs so plain node can render templates (the PDF code is TypeScript + JSX).
export { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, makeItems } from "../../src/pdf/templates/harness";
export type { FixtureSpec } from "../../src/pdf/templates/harness";
export { TEMPLATES } from "../../src/pdf/templates/registry";
+82
View File
@@ -0,0 +1,82 @@
// Shared by the template scripts (thumbnails, compare): bundles the TypeScript renderer for node and
// rasterises PDFs with pdf.js + @napi-rs/canvas.
import { build } from "esbuild";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { ROOT } from "../logo/lib.mjs";
export { ROOT };
export const CANVA_DIR = path.join(ROOT, "templates", "Modern Neutral Invoice Template");
/**
* Bundles scripts/templates/entry.ts into node_modules/.cache (so bare imports such as react resolve from
* node_modules) and imports it. Node packages stay external; only the app's own sources are bundled.
*/
export async function loadRenderer() {
const dir = path.join(ROOT, "node_modules", ".cache", "voiced-templates");
mkdirSync(dir, { recursive: true });
const outfile = path.join(dir, `bundle-${Date.now()}.mjs`);
await build({
entryPoints: [path.join(ROOT, "scripts/templates/entry.ts")],
outfile,
bundle: true,
packages: "external",
platform: "node",
format: "esm",
jsx: "automatic",
logLevel: "error",
});
return import(pathToFileURL(outfile).href);
}
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);
}
}
/** Rasterises page `pageNo` of PDF bytes at `scale` on a white background; returns the canvas. */
export async function rasterize(bytes, pageNo = 1, scale = 1.6) {
const { createCanvas } = await loadCanvas();
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const task = pdfjs.getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true, isEvalSupported: false, verbosity: 0 });
const doc = await task.promise;
try {
const page = await doc.getPage(pageNo);
const vp = page.getViewport({ scale });
const canvas = createCanvas(Math.ceil(vp.width), Math.ceil(vp.height));
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx, canvas, viewport: vp }).promise;
return canvas;
} finally {
await task.destroy();
}
}
/** Text items of page 1 with baselines measured from the page top, for numeric comparison. */
export async function textItems(bytes, pageNo = 1) {
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const task = pdfjs.getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true, isEvalSupported: false, verbosity: 0 });
const doc = await task.promise;
try {
const page = await doc.getPage(pageNo);
const vp = page.getViewport({ scale: 1 });
const content = await page.getTextContent();
return content.items
.filter((it) => "str" in it && it.str.trim() !== "")
.map((it) => {
const m = vp.transform;
const [px, py] = [it.transform[4], it.transform[5]];
const [x, y] = [m[0] * px + m[2] * py + m[4], m[1] * px + m[3] * py + m[5]];
return { str: it.str, x: Math.round(x * 10) / 10, baseline: Math.round(y * 10) / 10, w: Math.round(it.width * 10) / 10, size: Math.round(Math.hypot(it.transform[0], it.transform[1]) * 10) / 10 };
});
} finally {
await task.destroy();
}
}