Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone b3b80918bc912dda8c53a48d
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/pdf.ts
// The PDF, made in the browser with pdf-lib and the standard Helvetica fonts. Clean, no watermark,
// the logo when there is one, and as many pages as the lines need (the table header repeats).
import { PDFDocument, StandardFonts, rgb, type PDFFont, type PDFPage, type PDFImage } from "pdf-lib";
import { computeTotals, docWord, formatMoney, lineAmount, lineRate, localDay, minorDigits, type InvoiceData } from "../functions/lib/invoice";
import { fmtDate, fmtQty, paperSize, perLineTax, fileName } from "./format";
const INK = rgb(0.08, 0.09, 0.11);
const MUTED = rgb(0.37, 0.39, 0.42);
const LINE = rgb(0.86, 0.87, 0.89);
const FILL = rgb(0.955, 0.96, 0.97);
const GREEN = rgb(0.18, 0.42, 0.27);
export interface PdfStatus { paidAt?: number | null }
export async function makePdf(data: InvoiceData, status: PdfStatus = {}): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const word = docWord(data);
const estimate = data.kind === "estimate";
const paidAt = estimate ? null : status.paidAt;
doc.setTitle(`${word} ${data.number}`);
doc.setAuthor(data.from.name);
doc.setSubject(`${word} ${data.number} for ${data.to.name}`);
doc.setCreator("Invoice by OpenApps");
doc.setProducer("Invoice by OpenApps");
const font = await doc.embedFont(StandardFonts.Helvetica);
const bold = await doc.embedFont(StandardFonts.HelveticaBold);
const charset = new Set(font.getCharacterSet());
// The standard fonts cover Western European text. Anything else is written without its accent,
// or as "?" when there is no close letter, instead of failing the whole download.
const safe = (s: string) => {
let out = "";
for (const ch of s.replace(/[\u202f\u2009\u2007\t]/g, " ").replace(/\u2212/g, "-")) {
const cp = ch.codePointAt(0)!;
if (charset.has(cp)) { out += ch; continue; }
const plain = ch.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const extra: Record<string, string> = { "ł": "l", "Ł": "L", "đ": "d", "Đ": "D", "ı": "i" };
if (plain && [...plain].every((c) => charset.has(c.codePointAt(0)!))) out += plain;
else out += extra[ch] ?? "?";
}
return out;
};
const cur = data.currency;
const money = (minor: number) => {
const s = formatMoney(minor, cur).replace(/[\u202f\u2009\u2007]/g, " ");
if ([...s].every((c) => charset.has(c.codePointAt(0)!))) return s;
try {
return safe(new Intl.NumberFormat(undefined, { style: "currency", currency: cur, currencyDisplay: "code" }).format(minor / 10 ** minorDigits(cur)));
} catch {
return safe(s);
}
};
const [W, H] = paperSize() === "A4" ? [595.28, 841.89] : [612, 792];
const M = 50;
const BOTTOM = M + 24;
const pages: PDFPage[] = [];
let page = doc.addPage([W, H]);
pages.push(page);
let y = H - M;
const width = (s: string, f: PDFFont, size: number) => f.widthOfTextAtSize(s, size);
const draw = (s: string, x: number, yy: number, size: number, f = font, color = INK) => {
if (s) page.drawText(safe(s), { x, y: yy, size, font: f, color });
};
const drawRight = (s: string, right: number, yy: number, size: number, f = font, color = INK) => {
const t = safe(s);
if (t) page.drawText(t, { x: right - width(t, f, size), y: yy, size, font: f, color });
};
/** Splits text into lines that fit, keeping the person's own line breaks. */
const wrap = (s: string, f: PDFFont, size: number, max: number): string[] => {
const out: string[] = [];
for (const para of s.split(/\r?\n/).map(safe)) {
let line = "";
for (const word of para.split(/ +/)) {
let w = word;
while (width(w, f, size) > max) {
// A word longer than the column is broken where it no longer fits.
let cut = w.length - 1;
while (cut > 1 && width(w.slice(0, cut), f, size) > max) cut--;
if (line) { out.push(line); line = ""; }
out.push(w.slice(0, cut));
w = w.slice(cut);
}
const next = line ? `${line} ${w}` : w;
if (width(next, f, size) <= max) line = next;
else { out.push(line); line = w; }
}
out.push(line);
}
return out;
};
const newPage = () => {
page = doc.addPage([W, H]);
pages.push(page);
y = H - M;
};
const ensure = (needed: number) => { if (y - needed < BOTTOM) newPage(); };
// Header: logo and business on the left, the title and dates on the right.
let logo: PDFImage | null = null;
if (data.from.logo) {
try {
const bytes = Uint8Array.from(atob(data.from.logo.split(",")[1] || ""), (c) => c.charCodeAt(0));
logo = data.from.logo.startsWith("data:image/png") ? await doc.embedPng(bytes) : await doc.embedJpg(bytes);
} catch { logo = null; }
}
const colW = (W - 2 * M) * 0.55;
let left = y;
if (logo) {
const s = Math.min(160 / logo.width, 56 / logo.height, 1);
const lw = logo.width * s;
const lh = logo.height * s;
page.drawImage(logo, { x: M, y: left - lh, width: lw, height: lh });
left -= lh + 16;
}
for (const line of wrap(data.from.name, bold, 13, colW)) { draw(line, M, left - 12, 13, bold); left -= 17; }
const fromLines = [...(data.from.address ? wrap(data.from.address, font, 9.5, colW) : []), ...(data.from.email ? [data.from.email] : []), ...(data.from.taxId ? [`Tax ID: ${data.from.taxId}`] : [])];
left -= 2;
for (const line of fromLines) { draw(line, M, left - 10, 9.5, font, MUTED); left -= 13.5; }
let right = y;
drawRight(word.toUpperCase(), W - M, right - 22, 24, bold);
right -= 42;
const meta: [string, string][] = [[`${word} no.`, data.number], ["Issue date", fmtDate(data.issueDate)]];
if (!estimate) meta.push(["Due date", data.dueDate ? fmtDate(data.dueDate) : "On receipt"]);
else if (data.dueDate) meta.push(["Valid until", fmtDate(data.dueDate)]);
if (paidAt) meta.push(["Paid on", fmtDate(localDay(new Date(paidAt * 1000)))]);
for (const [label, value] of meta) {
draw(label, W - M - 190, right - 10, 9.5, font, MUTED);
drawRight(value, W - M, right - 10, 9.5, bold, label === "Paid on" ? GREEN : INK);
right -= 15;
}
y = Math.min(left, right) - 26;
// Bill to, and the amount due.
const totals = computeTotals(data);
let billY = y;
draw(estimate ? "FOR" : "BILL TO", M, billY - 9, 8, bold, MUTED);
billY -= 22;
for (const line of wrap(data.to.name, bold, 11.5, colW)) { draw(line, M, billY - 10, 11.5, bold); billY -= 15; }
const toLines = [...(data.to.address ? wrap(data.to.address, font, 9.5, colW) : []), ...(data.to.email ? [data.to.email] : [])];
for (const line of toLines) { draw(line, M, billY - 10, 9.5, font, MUTED); billY -= 13.5; }
drawRight(paidAt ? "PAID" : estimate ? "ESTIMATE TOTAL" : "AMOUNT DUE", W - M, y - 9, 8, bold, paidAt ? GREEN : MUTED);
drawRight(money(totals.total), W - M, y - 40, 22, bold);
y = Math.min(billY, y - 48) - 24;
// Lines.
const showTax = perLineTax(data);
const numW = { qty: 54, price: 86, tax: 50, amount: 92 };
const xAmount = W - M;
const xTax = xAmount - numW.amount;
const xPrice = showTax ? xTax - numW.tax : xTax;
const xQty = xPrice - numW.price;
const descW = xQty - numW.qty - M - 12;
const header = () => {
page.drawRectangle({ x: M, y: y - 22, width: W - 2 * M, height: 22, color: FILL });
draw("DESCRIPTION", M + 8, y - 14.5, 7.5, bold, MUTED);
drawRight("QTY", xQty, y - 14.5, 7.5, bold, MUTED);
drawRight("PRICE", xPrice, y - 14.5, 7.5, bold, MUTED);
if (showTax) drawRight("TAX", xTax, y - 14.5, 7.5, bold, MUTED);
drawRight("AMOUNT", xAmount - 8, y - 14.5, 7.5, bold, MUTED);
y -= 22;
};
header();
data.items.forEach((it, i) => {
const lines = wrap(it.description, font, 9.5, descW);
const rowH = lines.length * 13 + 12;
if (y - rowH < BOTTOM) { newPage(); header(); }
let ly = y - 16;
for (const line of lines) { draw(line, M + 8, ly, 9.5); ly -= 13; }
drawRight(fmtQty(it.quantity), xQty, y - 16, 9.5);
drawRight(money(lineAmount({ quantity: "1", unitPrice: it.unitPrice }, cur) || 0), xPrice, y - 16, 9.5);
if (showTax) drawRight(`${lineRate(it, data)}%`, xTax, y - 16, 9.5, font, MUTED);
drawRight(money(totals.lines[i]), xAmount - 8, y - 16, 9.5);
y -= rowH;
page.drawLine({ start: { x: M, y }, end: { x: W - M, y }, thickness: 0.6, color: LINE });
});
// Totals, right-aligned.
const rows: [string, string][] = [["Subtotal", money(totals.subtotal)]];
if (totals.discount > 0) rows.push([data.discount?.type === "percent" ? `Discount (${data.discount.value}%)` : "Discount", `-${money(totals.discount)}`]);
for (const t of totals.taxes) rows.push([`Tax (${t.rate}%)`, money(t.amount)]);
ensure(rows.length * 17 + 50);
y -= 14;
const labelX = W - M - 230;
for (const [label, value] of rows) {
draw(label, labelX, y - 10, 9.5, font, MUTED);
drawRight(value, xAmount - 8, y - 10, 9.5);
y -= 17;
}
y -= 4;
page.drawLine({ start: { x: labelX, y }, end: { x: W - M, y }, thickness: 0.8, color: INK });
y -= 6;
draw("Total", labelX, y - 13, 12, bold);
drawRight(money(totals.total), xAmount - 8, y - 13, 12, bold);
y -= 22;
// Notes and payment instructions, full width.
const block = (title: string, text?: string) => {
if (!text?.trim()) return;
y -= 18;
ensure(34);
draw(title.toUpperCase(), M, y - 9, 8, bold, MUTED);
y -= 22;
for (const line of wrap(text, font, 9.5, W - 2 * M)) {
ensure(14);
draw(line, M, y - 10, 9.5);
y -= 13.5;
}
};
block("Payment instructions", data.paymentInstructions);
block("Notes", data.notes);
// Footer on every page: the invoice number, and page numbers when there is more than one.
pages.forEach((p, i) => {
p.drawText(safe(`${data.number} · ${data.from.name}`).slice(0, 90), { x: M, y: M - 16, size: 8, font, color: MUTED });
if (pages.length > 1) {
const t = `Page ${i + 1} of ${pages.length}`;
p.drawText(t, { x: W - M - font.widthOfTextAtSize(t, 8), y: M - 16, size: 8, font, color: MUTED });
}
});
return doc.save();
}
/** Builds the PDF and hands it to the browser as a download. */
export async function downloadPdf(data: InvoiceData, status: PdfStatus = {}) {
const bytes = await makePdf(data, status);
const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
const a = document.createElement("a");
a.href = url;
a.download = fileName(data);
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
Change history
Quality pass: phone-readable invoice, estimates, email client, saved clients, CSV export, openapi.json
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync