// ═══════════════════════════════════════════════════════════════════════════
// invoice-cli — Shared component library
//
// Components handle STRUCTURE, OVERFLOW, PAGINATION, SPACING.
// Templates handle AESTHETIC (palette, fonts, hero composition).
//
// Theme dict (all keys optional — components read with defaults):
// Colours: ink, paper, accent, accent-soft, mute, hair, dim
// Fonts: display-font, body-font, mono-font
// Labels: label-style : "upper" | "smallcaps" | "mono-tag"
// Tax behaviour: tax-zero : "dash" | "percent" | "hide"
// hide-zero-tax : bool (suppress 0% lines in totals)
// Totals look: totals-variant : "minimal" | "dark-block" | "soft-fill"
// | "framed-gold" | "signal-bar"
// Page: margin, fold-marks, pagination-strip, compact-strip
// ═══════════════════════════════════════════════════════════════════════════
#import "invoice.typ": money
// ─── Spacing scale ─────────────────────────────────────────────────────────
// One source of truth for vertical rhythm. Keeps sections feeling natural.
#let sp = (
xxs: 2pt,
xs: 4pt,
s: 6pt,
m: 10pt,
l: 16pt,
xl: 24pt,
)
// mm scale for page-level separations
#let mm-sp = (
xs: 3mm,
s: 5mm,
m: 8mm,
l: 12mm,
xl: 16mm,
)
// ─── Theme helper ──────────────────────────────────────────────────────────
#let th(theme, key, default) = if key in theme { theme.at(key) } else { default }
// ─── Dynamic text-size fitting via measure() ───────────────────────────────
#let fit-size(sizes, max-width, make) = context {
let chosen = sizes.last()
for s in sizes {
if measure(make(s)).width <= max-width {
chosen = s
break
}
}
make(chosen)
}
// ─── Label rendering respects theme.label-style ────────────────────────────
#let lbl(theme, txt, size: 7.5pt, tracking: 1.2pt, fill: none, weight: 500) = {
let style = th(theme, "label-style", "upper")
let f = if fill == none { th(theme, "mute", rgb("#666")) } else { fill }
if style == "smallcaps" {
text(
font: th(theme, "display-font", ("Helvetica Neue", "Helvetica", "Arial", "New Computer Modern")),
size: size + 0.3pt, tracking: tracking + 0.2pt, fill: f, weight: weight,
)[#upper(txt)]
} else if style == "mono-tag" {
text(font: th(theme, "mono-font", ("Menlo", "DejaVu Sans Mono")), size: size - 0.5pt, tracking: tracking - 0.3pt, fill: f, weight: weight)[#upper(txt)]
} else {
text(size: size, tracking: tracking, fill: f, weight: weight)[#upper(txt)]
}
}
// ─── Party block — handles optional fields cleanly ─────────────────────────
// Strict type scale across ALL templates — 3 sizes only:
// 11pt — party heading (bold, display font)
// 9.5pt — body (everything else: addresses, amounts, dates, bank details,
// attn, tax-id, legal name, notes, item descriptions)
// 7.5pt — labels (upper tracked)
//
// Hierarchy comes from WEIGHT (bold vs regular) and COLOUR (ink vs mute),
// never from size. Size only varies for headings and labels, nowhere else.
#let party-block(party, theme, label-text: "To", show-name: true) = {
let mute = th(theme, "mute", rgb("#666"))
let display = th(theme, "display-font", ("Helvetica Neue", "Helvetica", "Arial", "New Computer Modern"))
let has(k) = k in party and party.at(k) != none
lbl(theme, label-text)
v(sp.s)
if show-name {
text(font: display, size: 11pt, weight: 600)[#party.name]
linebreak()
}
if show-name and has("legal-name") and party.legal-name != party.name {
text(size: 9.5pt, fill: mute, style: "italic")[#party.legal-name]
linebreak()
}
if has("attn") and party.attn != party.name {
text(size: 9.5pt, fill: mute)[#party.attn]
linebreak()
}
for line in party.address [#text(size: 9.5pt)[#line]\ ]
let id-bits = ()
if has("tax-id") { id-bits.push(party.tax-id) }
if has("company-no") { id-bits.push("Co. " + party.company-no) }
if id-bits.len() > 0 {
text(size: 9.5pt, fill: mute)[#id-bits.join(" · ")]
}
}
// ─── Meta block — stacked or inline-grid ──────────────────────────────────
#let meta-block(pairs, theme, layout: "stacked", emphasize: ()) = {
let mute = th(theme, "mute", rgb("#666"))
let accent = th(theme, "accent", rgb("#333"))
if layout == "stacked" {
for (lab, val) in pairs [
#lbl(theme, lab)
#v(sp.xs)
#if lab in emphasize {
text(size: 10.5pt, weight: 600, fill: accent)[#val]
} else {
text(size: 10pt)[#val]
}
#v(sp.m)
]
} else {
grid(
columns: (auto, 1fr),
row-gutter: sp.s,
column-gutter: sp.m,
..pairs.map(((lab, val)) => (
lbl(theme, lab),
if lab in emphasize {
text(size: 10pt, weight: 600, fill: accent)[#val]
} else {
text(size: 10pt)[#val]
},
)).flatten()
)
}
}
// ─── Line items table ──────────────────────────────────────────────────────
#let line-items-table(items, theme, currency-symbol: "S$", tax-label: "Tax") = {
let mute = th(theme, "mute", rgb("#666"))
let hair = th(theme, "hair", rgb("#e0e0e0"))
let accent = th(theme, "accent", rgb("#333"))
let display = th(theme, "display-font", ("Helvetica Neue", "Helvetica", "Arial", "New Computer Modern"))
let zero-style = th(theme, "tax-zero", "dash")
let fmt-qty(q) = if calc.rem(q, 1) == 0 { str(int(q)) } else { str(q) }
let fmt-tax(r) = {
if r == 0.0 {
if zero-style == "dash" { "—" }
else if zero-style == "hide" { "" }
else { "0%" }
} else {
str(int(r)) + "%"
}
}
table(
// Description flexes; numeric columns sized to content. Column-gutter
// provides inter-column breathing room without per-column inset hacks —
// ensures description text flushes left AND amount text flushes right.
columns: (1fr, auto, auto, auto, auto),
column-gutter: 16pt,
stroke: none,
inset: (x: 0pt, y: 9pt),
align: (col, row) => if col == 0 { left + horizon } else { right + horizon },
table.header(
lbl(theme, "Description"),
lbl(theme, "Qty"),
lbl(theme, "Rate"),
lbl(theme, tax-label),
lbl(theme, "Amount"),
),
table.hline(stroke: 0.5pt + accent, y: 1),
..items.map(item => (
block(breakable: false)[
#text(font: display, size: 9.5pt, weight: 600)[#item.description]\
#text(size: 9.5pt, fill: mute, style: "italic")[#item.subtitle]
],
text(size: 9.5pt)[#fmt-qty(item.qty)],
text(size: 9.5pt)[#money(item.unit-price, symbol: currency-symbol)],
text(size: 9.5pt, fill: mute)[#fmt-tax(item.tax-rate)],
text(size: 9.5pt, weight: 600, font: display)[#money(item.qty * item.unit-price, symbol: currency-symbol)],
)).flatten(),
table.hline(stroke: 0.3pt + hair),
)
}
// ─── Totals internals ─────────────────────────────────────────────────────
#let _totals-rows(totals, theme, currency-symbol, label-fn, tax-label) = {
let rows = (
label-fn("Subtotal"),
text(size: 9.5pt)[#money(totals.subtotal, symbol: currency-symbol)],
)
for tl in totals.tax-lines {
let hide-zero = th(theme, "hide-zero-tax", false)
if tl.amount == 0.0 and hide-zero { continue }
let rate-txt = if tl.rate == 0 { "zero-rated" } else { "@ " + str(int(tl.rate)) + "%" }
rows.push(label-fn(tax-label + " " + rate-txt))
rows.push(text(size: 9.5pt)[#money(tl.amount, symbol: currency-symbol)])
}
rows
}
#let _fit-total(total-str, font, weight, max-width, accent: none, sizes: (18pt, 16pt, 15pt, 13pt, 12pt)) = {
fit-size(
sizes,
max-width,
s => {
let opts = (size: s, weight: weight, font: font)
if accent != none { opts.insert("fill", accent) }
text(..opts, tracking: -0.2pt)[#total-str]
},
)
}
#let tax-totals(totals, theme, currency-symbol: "S$", width: 84mm, tax-label: "Tax", total-label: none) = {
let variant = th(theme, "totals-variant", "minimal")
let mute = th(theme, "mute", rgb("#666"))
let accent = th(theme, "accent", rgb("#333"))
let ink = th(theme, "ink", black)
let paper = th(theme, "paper", white)
let accent-soft = th(theme, "accent-soft", rgb("#f2f2f2"))
let dim = th(theme, "dim", rgb("#ccc"))
let display = th(theme, "display-font", ("Helvetica Neue", "Helvetica", "Arial", "New Computer Modern"))
// Tax-row labels (Subtotal / GST @ 9% / …) match body size (9.5pt) so the
// rhythm across the doc reads as one voice; colour alone distinguishes
// label from value.
let label-mut(t) = text(size: 9.5pt, fill: mute)[#t]
let total-label-text(default) = if total-label == none { default } else { total-label }
if variant == "minimal" {
align(right)[
#box(width: width)[
#grid(
columns: (1fr, auto),
row-gutter: sp.s,
column-gutter: sp.l,
align: (left, right),
.._totals-rows(totals, theme, currency-symbol, label-mut, tax-label),
)
#v(sp.m)
#line(length: 100%, stroke: 0.5pt + accent)
#v(sp.m)
#grid(
columns: (1fr, auto),
column-gutter: sp.l,
align: (left + horizon, right + horizon),
text(size: 11pt, weight: 600)[#total-label-text("Total due")],
_fit-total(money(totals.total, symbol: currency-symbol), display, 500, width - 40mm),
)
]
]
} else if variant == "framed-gold" {
align(right)[
#box(width: width)[
#line(length: 100%, stroke: 0.5pt + accent)
#v(sp.m)
#grid(
columns: (1fr, auto),
row-gutter: sp.s,
column-gutter: sp.xl,
align: (left, right),
.._totals-rows(totals, theme, currency-symbol, t => text(size: 9.5pt, fill: mute, style: "italic")[#t], tax-label),
)
#v(sp.m)
#line(length: 100%, stroke: 0.3pt + accent)
#v(sp.m)
#grid(
columns: (1fr, auto),
column-gutter: sp.xl,
align: (left + horizon, right + horizon),
text(font: display, size: 12pt, style: "italic")[#total-label-text("Total payable")],
_fit-total(money(totals.total, symbol: currency-symbol), display, 500, width - 42mm),
)
#v(sp.m)
#line(length: 100%, stroke: 0.5pt + accent)
]
]
} else if variant == "signal-bar" {
align(right)[
#box(width: width)[
#grid(
columns: (1fr, auto),
row-gutter: sp.xs,
column-gutter: sp.m,
align: (left, right),
.._totals-rows(totals, theme, currency-symbol, label-mut, tax-label),
)
#v(sp.s)
#line(length: 100%, stroke: 0.8pt + accent)
#v(sp.s)
#grid(
columns: (1fr, auto),
column-gutter: sp.m,
align: (left + horizon, right + horizon),
lbl(theme, total-label-text("Total due") + " →", fill: ink, size: 9pt),
_fit-total(money(totals.total, symbol: currency-symbol), th(theme, "mono-font", ("Menlo", "DejaVu Sans Mono")), 600, width - 36mm, accent: accent),
)
]
]
} else if variant == "dark-block" {
// Asymmetric pad: content has left gutter, right flush so amounts align
// with the line-items-table AMOUNT column (which itself is flush-right).
align(right)[
#block(width: width, fill: ink)[
#pad(left: sp.l, top: sp.l, bottom: sp.l, right: 0pt)[
#set text(fill: paper)
#grid(
columns: (1fr, auto),
row-gutter: sp.s,
column-gutter: sp.l,
align: (left, right),
.._totals-rows(totals, theme, currency-symbol, t => text(size: 9pt, fill: dim, tracking: 1pt)[#upper(t)], tax-label),
)
#v(sp.m)
#line(length: 100%, stroke: 0.5pt + dim)
#v(sp.m)
#grid(
columns: (1fr, auto),
column-gutter: sp.m,
align: (left + horizon, right + horizon),
text(size: 10pt, fill: paper, tracking: 1.4pt, weight: 500)[#upper(total-label-text("Total due"))],
_fit-total(money(totals.total, symbol: currency-symbol), display, 700, width - 38mm, accent: accent, sizes: (18pt, 16pt, 15pt, 13pt, 12pt)),
)
]
]
]
} else if variant == "ledger" {
// No fill. Hairlines only. Total figure picks up accent colour.
align(right)[
#box(width: width)[
#grid(
columns: (1fr, auto),
row-gutter: sp.s,
column-gutter: sp.l,
align: (left, right),
.._totals-rows(totals, theme, currency-symbol, label-mut, tax-label),
)
#v(sp.m)
#line(length: 100%, stroke: 0.8pt + accent)
#v(sp.m)
#grid(
columns: (1fr, auto),
column-gutter: sp.l,
align: (left + horizon, right + horizon),
text(size: 10pt, tracking: 1.4pt, weight: 600)[#upper(total-label-text("Total due"))],
_fit-total(money(totals.total, symbol: currency-symbol), display, 600, width - 40mm, accent: accent, sizes: (18pt, 16pt, 15pt, 13pt, 12pt)),
)
]
]
} else if variant == "stamp-outline" {
// Outlined accent box around only the total. Tax lines sit quietly above.
align(right)[
#box(width: width)[
#grid(
columns: (1fr, auto),
row-gutter: sp.s,
column-gutter: sp.l,
align: (left, right),
.._totals-rows(totals, theme, currency-symbol, label-mut, tax-label),
)
#v(sp.m)
#block(
width: 100%,
stroke: 1.2pt + accent,
inset: sp.m,
)[
#grid(
columns: (1fr, auto),
column-gutter: sp.l,
align: (left + horizon, right + horizon),
text(size: 10pt, tracking: 1.4pt, weight: 600, fill: accent)[#upper(total-label-text("Total due"))],
_fit-total(money(totals.total, symbol: currency-symbol), display, 700, width - 40mm, accent: accent, sizes: (17pt, 15pt, 14pt, 13pt, 12pt)),
)
]
]
]
} else if variant == "soft-fill" {
// Symmetric inset — the soft-green card is a cohesive object and
// deserves internal breathing room on all sides.
align(right)[
#block(width: width, fill: accent-soft, inset: sp.l, radius: 2pt)[
#grid(
columns: (1fr, auto),
row-gutter: sp.s,
column-gutter: sp.l,
align: (left, right),
.._totals-rows(totals, theme, currency-symbol, label-mut, tax-label),
)
#v(sp.m)
#line(length: 100%, stroke: 0.5pt + accent)
#v(sp.m)
#grid(
columns: (1fr, auto),
column-gutter: sp.m,
align: (left + horizon, right + horizon),
text(font: display, size: 12pt, style: "italic", fill: accent)[#total-label-text("Total due")],
_fit-total(money(totals.total, symbol: currency-symbol), display, 600, width - 36mm, accent: accent),
)
]
]
}
}
// ─── QR code renderer ─────────────────────────────────────────────────────
// Accepts a boolean matrix (the real QR module grid encoded in Rust using
// the `qrcode` crate) and renders it with themed colours and styled modules.
//
// modules: array of arrays of bool — outer = rows, inner = columns
// size: overall rendered size (square)
// fg: module colour
// bg: background / quiet-zone colour
// style: "square" (default) | "dots" | "rounded"
//
// For placeholder previews (no data) you can still call epc-qr(..) which
// generates a fake-looking pattern — useful when just previewing templates.
#let qr-render(modules, size: 22mm, fg: black, bg: white, style: "square", quiet-zone: 2) = {
if modules == none or modules.len() == 0 { return }
let n = modules.len() + quiet-zone * 2
let cell = size / n
let finder-size = 7 // modules in each finder pattern (QR standard)
// Finder pattern positions (top-left corner in module coords, excluding quiet zone)
let finders = (
(quiet-zone, quiet-zone), // top-left
(quiet-zone, quiet-zone + modules.len() - finder-size), // top-right (visually top-right of the matrix)
(quiet-zone + modules.len() - finder-size, quiet-zone), // bottom-left (visually bottom-left)
)
// Is this cell inside a finder pattern?
let in-finder(r, c) = {
for (fr, fc) in finders {
if r >= fr and r < fr + finder-size and c >= fc and c < fc + finder-size {
return true
}
}
false
}
box(width: size, height: size, fill: bg)[
// Draw data modules
#for row in range(modules.len()) {
for col in range(modules.at(row).len()) {
if modules.at(row).at(col) and not in-finder(row + quiet-zone, col + quiet-zone) {
let x = (col + quiet-zone) * cell
let y = (row + quiet-zone) * cell
if style == "dots" {
place(top + left, dx: x, dy: y,
circle(radius: cell * 0.42, fill: fg))
} else if style == "rounded" {
place(top + left, dx: x + cell * 0.08, dy: y + cell * 0.08,
rect(width: cell * 0.84, height: cell * 0.84, radius: cell * 0.25, fill: fg))
} else {
place(top + left, dx: x, dy: y,
rect(width: cell, height: cell, fill: fg))
}
}
}
}
// Draw styled finder patterns (3 corners)
#for (fr, fc) in finders {
let x = fc * cell
let y = fr * cell
let outer = cell * finder-size
let ring-w = cell
let inner-outer = outer - ring-w * 2
let inner = inner-outer * 0.5
let radius-outer = if style == "dots" or style == "rounded" { cell * 1.6 } else { 0pt }
let radius-inner = if style == "dots" or style == "rounded" { cell * 0.6 } else { 0pt }
// Outer filled square (with ring punch-out)
place(top + left, dx: x, dy: y,
rect(width: outer, height: outer, radius: radius-outer, fill: fg))
// Inner background ring
place(top + left, dx: x + ring-w, dy: y + ring-w,
rect(width: outer - ring-w * 2, height: outer - ring-w * 2,
radius: radius-outer * 0.6, fill: bg))
// Inner solid square
place(top + left, dx: x + ring-w * 2, dy: y + ring-w * 2,
rect(width: outer - ring-w * 4, height: outer - ring-w * 4,
radius: radius-inner, fill: fg))
}
]
}
// ─── EPC-QR placeholder (for template previews without real data) ─────────
#let epc-qr(size: 22mm, fg: black, bg: white) = {
box(width: size, height: size, fill: bg, stroke: 0.5pt + fg)[
#place(center + horizon, block(width: size - 4mm, height: size - 4mm, {
let inner = size - 4mm
let cells = 10
let cell = inner / cells
for row in range(cells) {
for col in range(cells) {
let seed = calc.rem(row * 7 + col * 3 + row * col, 5)
if seed < 2 {
place(top + left, dx: col * cell, dy: row * cell, rect(width: cell, height: cell, fill: fg))
}
}
}
for (dx, dy) in ((0mm, 0mm), (inner - 5.5mm, 0mm), (0mm, inner - 5.5mm)) {
place(top + left, dx: dx, dy: dy, rect(width: 5.5mm, height: 5.5mm, fill: fg))
place(top + left, dx: dx + 1.1mm, dy: dy + 1.1mm, rect(width: 3.3mm, height: 3.3mm, fill: bg))
place(top + left, dx: dx + 1.9mm, dy: dy + 1.9mm, rect(width: 1.7mm, height: 1.7mm, fill: fg))
}
}))
]
}
// ─── Payment block ────────────────────────────────────────────────────────
// Renders bank / payment details as a two-column list. Input: `bank` dict
// with a `lines` array of `{label, value}` rows (from finance-core's
// BankLine::parse_all). Handles every country — SG bank code, UK sort code,
// US ABA routing, EU IBAN, AU BSB — because the caller decides the labels.
// If `bank` is none (issuer has no bank_details set) the block renders
// nothing, letting the template lay out without a crash.
//
// Typography: labels and values share the same 9.5pt size for visual
// consistency. Distinction comes from colour (mute for labels) and font
// family (monospace for values — clean for account numbers, codes, BIC).
// The only smaller text is the block heading ("Pay to") which inherits
// its styling from the `lbl` helper.
#let payment-block(bank, theme, label-text: "Pay to") = {
if bank == none { return }
let mute = th(theme, "mute", rgb("#666"))
let mono = th(theme, "mono-font", ("Menlo", "DejaVu Sans Mono"))
lbl(theme, label-text)
v(sp.s)
let cells = ()
for line in bank.lines {
if line.label != "" {
cells.push(text(size: 8.5pt, fill: mute, tracking: 0.02em)[#line.label:])
cells.push(text(size: 8.5pt, font: mono, number-width: "tabular")[#line.value])
} else {
// Continuation line without a label — span both columns visually.
cells.push([])
cells.push(text(size: 8.5pt, font: mono, fill: mute, number-width: "tabular")[#line.value])
}
}
grid(
columns: (auto, 1fr),
column-gutter: sp.s,
row-gutter: sp.xs,
// Right-align labels so the hierarchy shows as a
// clean invisible axis between label column and value column.
align: (right + top, left + top),
..cells,
)
}
#let notes-block(text-body, theme, label-text: "Notes") = {
let mute = th(theme, "mute", rgb("#666"))
lbl(theme, label-text)
v(sp.s)
text(size: 8.5pt, fill: mute)[#text-body]
}
// ─── DIN 5008 fold marks ─────────────────────────────────────────────────
#let fold-marks-placement(theme) = {
let ink = th(theme, "ink", black)
place(top + left, dx: 6mm, dy: 87mm, line(length: 3mm, stroke: 0.3pt + ink))
place(top + left, dx: 6mm, dy: 148.5mm, line(length: 2mm, stroke: 0.2pt + ink))
place(top + left, dx: 6mm, dy: 192mm, line(length: 3mm, stroke: 0.3pt + ink))
}
// ─── Compact page-2+ header strip (invoice-no shrink-to-fit) ─────────────
#let compact-strip(theme, issuer, invoice) = {
let mute = th(theme, "mute", rgb("#666"))
let hair = th(theme, "hair", rgb("#e0e0e0"))
let inset-x = th(theme, "page-inset-x", 0mm)
pad(top: mm-sp.s, bottom: 0mm, x: inset-x)[
#grid(
columns: (1fr, auto),
align: (left + horizon, right + horizon),
context fit-size(
(8pt, 7.5pt, 7pt),
140mm,
s => text(size: s, fill: mute, tracking: 0.3pt)[#upper(issuer.name) · Invoice #invoice.number],
),
context text(size: 7.5pt, fill: mute, tracking: 0.3pt)[
Page #here().page() / #counter(page).final().first()
],
)
#v(sp.xs)
#line(length: 100%, stroke: 0.3pt + hair)
]
}
// ─── Pagination footer — minimal, one line ────────────────────────────────
#let pagination-footer(theme, issuer) = {
let mute = th(theme, "mute", rgb("#666"))
let hair = th(theme, "hair", rgb("#e0e0e0"))
let inset-x = th(theme, "page-inset-x", 0mm)
let bits = ()
if "email" in issuer and issuer.email != none { bits.push(issuer.email) }
if "phone" in issuer and issuer.phone != none { bits.push(issuer.phone) }
pad(top: 0mm, bottom: mm-sp.s, x: inset-x)[
#line(length: 100%, stroke: 0.3pt + hair)
#v(sp.xs)
#grid(
columns: (1fr, auto),
align: (left + horizon, right + horizon),
text(size: 7pt, fill: mute, tracking: 0.4pt)[#bits.join(" · ")],
context text(size: 7pt, fill: mute, tracking: 0.4pt)[
#here().page() / #counter(page).final().first()
],
)
]
}
// ─── Page shell ───────────────────────────────────────────────────────────
#let page-shell(theme, issuer, invoice, body) = {
let use-folds = th(theme, "fold-marks", false)
let use-compact = th(theme, "compact-strip", auto) != none
let use-footer = th(theme, "pagination-strip", auto) != none
set page(
paper: "a4",
margin: th(theme, "margin", (top: 22mm, bottom: 22mm, left: 22mm, right: 22mm)),
fill: th(theme, "paper", white),
header: if use-compact { context if here().page() > 1 {
compact-strip(theme, issuer, invoice)
} },
footer: if use-footer { pagination-footer(theme, issuer) },
background: if use-folds { context if here().page() == 1 {
fold-marks-placement(theme)
} },
)
body
}