Skip to content
Lamina PDF

An invoice

You’ll make a one-page invoice: a heading, an address, a table of line items with right-aligned amounts, and a total. It’s tagged, so a screen reader reads it in order. Start from it for any document built around a table, such as a quote or a receipt.

The function takes a font file, draws the invoice and returns the PDF’s bytes. In TypeScript it needs the text module, init({ text: true }). Both languages write the same bytes:

packages/sdk/examples/invoice.ts
// An invoice that screen readers can follow, with an embedded font and a table of line items.
// The heading, the address, the table of items and the total are tagged, so a screen reader
// reads them in order.
import { Builder, type BuilderOptions, Font, PAGE_SIZES, type Rect, type Surface } from "@laminapdf/sdk";
const ITEMS: readonly (readonly [item: string, qty: number, price: number])[] = [
["Widgets, blue (W-100)", 3, 12.0],
["Gadget with fine-tuned dials", 1, 79.9],
["Café-grade sprockets", 12, 1.25],
["Delivery", 1, 4.5],
];
const ADDRESS = "Acme Widgets Ltd · 1 Example Street · London";
const PAGE = "A4";
const [PAGE_WIDTH] = PAGE_SIZES[PAGE];
const MARGIN = 56;
const RIGHT_EDGE = PAGE_WIDTH - MARGIN;
const TITLE_AT = [MARGIN, 80] as const;
const ADDRESS_AT = [MARGIN, 100] as const;
const TITLE = 24;
const BODY = 11;
const FIRST_ROW = 160;
const ROW_HEIGHT = 24;
/** The quantity's and the amount's columns, each a width ending this far left of the right edge. */
const COLUMNS = [
[40, 169],
[100, 0],
] as const;
/** A cell's box starts this far above its row's baseline and is this tall. */
const CELL_RISE = 12;
const CELL_HEIGHT = 16;
/**
* The document's bytes, in `fontBytes` (a TrueType or OpenType file). Needs `init({ text: true })`.
*
* @param options - can fix the file ID, the producer and the creation date.
*/
export function invoice(fontBytes: Uint8Array, options: BuilderOptions = {}): Uint8Array<ArrayBuffer> {
using b = new Builder({ ...options, tagged: true });
const font = Font.fromBytes(fontBytes);
const bold = Font.standard("Helvetica-Bold");
// Registered up front, in this order, so the embedded font is the document's first.
b.font(font);
b.font(bold);
const page = b.page(PAGE);
const s = page.surface();
s.text("Invoice 42", { font: bold, size: TITLE, at: TITLE_AT, tag: "H1" });
s.text(ADDRESS, { font, size: BODY, at: ADDRESS_AT, tag: "P" });
items(s, font);
const total = ITEMS.reduce((sum, [, qty, price]) => sum + qty * price, 0);
const box = cell(COLUMNS[1], rowBaseline(ITEMS.length));
s.textBox(`Total € ${total.toFixed(2)}`, { font, size: BODY, box, align: "end", tag: "P" });
page.finish();
b.metadata({ title: "Invoice 42", author: "Acme Widgets Ltd" }).lang("en-GB");
using doc = b.finish();
return doc.save();
}
/** The table of line items: what, how many, and the amount, right-aligned. */
function items(s: Surface, font: Font): void {
s.tag("Table", (s) => {
ITEMS.forEach(([item, qty, price], row) => {
const y = rowBaseline(row);
const cells = [
[String(qty), COLUMNS[0]],
[`€ ${(qty * price).toFixed(2)}`, COLUMNS[1]],
] as const;
s.tag("TR", (s) => {
s.text(item, { font, size: BODY, at: [MARGIN, y], tag: "TD" });
for (const [text, column] of cells) {
s.textBox(text, { font, size: BODY, box: cell(column, y), align: "end", tag: "TD" });
}
});
});
});
}
/** The baseline of row `row` of the table. */
function rowBaseline(row: number): number {
return FIRST_ROW + ROW_HEIGHT * row;
}
/** The box of a cell in `column` (width, distance from the right edge) on the row at `baseline`. */
function cell([width, inset]: readonly [number, number], baseline: number): Rect {
return [RIGHT_EDGE - inset - width, baseline - CELL_RISE, width, CELL_HEIGHT];
}

The result is one A4 page with its body set in Noto Sans: open the reference PDF.

  • The body uses your font file, and Lamina PDF embeds only the glyphs the invoice draws. The heading uses Helvetica Bold, one of the standard 14 fonts, so it embeds nothing.
  • Each quantity and amount is a text box aligned to its end, so the numbers line up on the right. cell works out each box from its column’s width and its distance from the right margin.
  • tagged: true (with_tagged(true) in Rust) gives the document a structure for screen readers. The tag option and s.tag(…) make the heading an H1, the address and the total each a paragraph (P), and the items a Table of rows (TR) and cells (TD).
  • metadata sets the title and author, and lang("en-GB") tells a screen reader which language to speak.
  • The line items: edit ITEMS. Each row sits ROW_HEIGHT points below the one before, and the total follows the last row.
  • The columns: each pair in COLUMNS is a column’s width and its distance from the right margin, in points.
  • The currency: change the € in the amount and total strings. Your font needs a glyph for the new symbol, or drawing fails with missing-glyph. See Text and fonts.
  • A PDF/UA-1 invoice: add profiles: ["PDF/UA-1"] (with_profile(Profile::PdfUa1) in Rust) to check the rules for accessible PDFs. PDF/UA-1 needs every font embedded, so load a bold font file for the heading. See Accessible PDFs.

Next: multilingual text, with five scripts, each in its own embedded font.