Skip to main content
Version: 0.6.0

Parts

A part is a renderer for one piece of output: the recipient, the title, the totals, the bank details. The layout places frame parts in its areas; the components (line-items, bank-details, payment-terms, signature) call the body parts. You can wrap a part, replace it, or add parts of your own.

The Part Contract​

ItemContract
Renderer(ctx, view) => content
Wrapper(ctx, view, inner) => content; inner(ctx, view) renders the part it wraps.
ctx.themeThe resolved theme: tokens, options, layout, env, … (see Testing a Theme).
ctx.localeThe locale: strings, format, currency, …
viewThe data of this part (see Views).

Every other key of ctx is internal and may change without notice.

#show: invoice.with(
theme: theme.classic.with({
import theme.custom: *
part("signature", (ctx, view) => [— #view.name]) // replace
wrap("bank-details", (ctx, view, inner) => block(
fill: ctx.theme.tokens.colors.tint,
inset: 8pt,
inner(ctx, view),
)) // wrap
part("totals", (ctx, view) => {
// eject: start from the built-in renderer, then change it
set text(fill: ctx.theme.tokens.colors.primary)
theme.parts.totals(ctx, view)
})
}),
// sender: .., recipient: .., invoice-nr: ..
)
HelperEffect
part(name, renderer)Replaces the renderer. part(name, none) hides an optional part; for a required part that is a theme issue.
wrap(name, wrapper)Wraps the renderer that is active so far, keeping its options.

Wraps stack in patch order: the preset's look first, then your patches, then themed scopes from the outside in. part(..) in any layer drops the wraps below it.

A part must follow these rules:

  • A required part must render something (see Built-in Parts).
  • A part reads only ctx.theme, ctx.locale and view. Output that depends on the page reads view.page.
  • A replaced items-table must create a real table.header, so the reading order of PDF/UA holds. The built-in table keeps the totals together with its last row; after a replaced table, the totals simply follow it.
  • A part that draws a color pair of its own should register it in checks.pairs.

Built-in Parts​

Part names are frozen. Custom parts need a package prefix, such as acme/rail; names without a prefix are reserved for future built-ins.

PartRendersCalled byRequirement
titleSubject, invoice number, place and date.framerole title; must show the number and the date
recipientThe recipient's name and address.framerole recipient
senderThe sender's name and address.framerole supplier
companyThe sender's name and address as a footer block.framerole supplier
return-addressThe one-line sender address above the window.framerole supplier
registrationCompany register, management, VAT ID and tax number.framerole tax-id
referencesThe references as a row.framerole tax-id
reference-listThe references as a label/value list.framerole tax-id
logoThe logo (logo options).frame–
sender-detailsThe sender's extra pairs, such as phone and e-mail.frame–
contactThe sender's extra pairs as a footer block.frame–
bank-accountIBAN (grouped in fours) and BIC as a footer block.frame–
page-number"Page 1 of 2" (page-number options, locale string document.page).frame–
continuationThe header of following pages: sender, subject and invoice number.frame–
marksFold and punch marks from the layout.frame–
qr-billThe Swiss QR-bill placeholder (0.6.x preview; only hosted by reserve-qr-bill).frame–
line-itemsThe composite: items-table, then totals.line-items componentrequired
items-tableThe table of items.the line-items partrequired
totalsThe totals block.the line-items partrequired
notesTax statements, shared unit, quantity and date, and legal notes such as tax exemptions.line-items component, after line-itemsrequired while legal notes are needed
bank-detailsBank details with the EPC-QR code.bank-details component–
payment-termsThe payment sentence.payment-terms component–
payment-meansA direct debit, a payment card or a paid invoice: a sentence and the details.direct-debit, card-payment, paid–
signatureClosing and signature.signature component–
zugferd-reportThe problems of the e-invoice, above the body, with zugferd-errors: "report".core–

The roles are explained in What Every Invoice Layout Must Host. company, contact, registration and bank-account take the text color of their area and never hyphenate. notes is called outside the line-items part, so replacing line-items cannot drop the legal notes. zugferd-report set to none, or a renderer that returns nothing, shows no report: the errors of the e-invoice then stop the compilation as with zugferd-errors: "panic", so none goes out unnoticed.

Compliance output never lives in a part: PDF metadata, the language, the e-invoice (factur-x.xml, or xrechnung.xml in XRechnung) and its validation, the decision which legal notes are required, the suppression of 0 % tax rows and the EPC-QR payload are produced by the core before any part runs.

Views​

A part receives the data it needs in view. Fields in bold are frozen; all others are provisional and may change in a 0.6.x minor release.

Frame View​

The frame parts (all parts called by the frame) receive the same record:

FieldTypeDescription
document.kindstrThe document kind; "invoice" in 0.6.0.
document.titlestr | contentThe document word from the locale (strings.document.invoice).
document.subjectstr | contentThe subject, without the invoice number.
document.numberstr | content | noneThe invoice number.
document.date(value: datetime, text: str)The invoice date and its formatted text.
document.placestr | noneThe sender's city.
sender.name, sender.linescontent, arrayThe name and the address lines. The other normalized party fields (vat-id, tax-nr, register, management, extra, country, name-inline, …) are provisional.
recipient.name, recipient.linescontent, arrayAs for the sender.
currencystrThe ISO 4217 currency code.
referencesarray(label, value) pairs of the reference block.
bankdictionary | noneThe data of the bank-details component.
totalsdictionarynet, gross, due and prepaid, each (value, text).
paymentdictionarydays of the payment terms and due, the due date as (value, text) or none.
page(current: int, total: int) | noneSet in headers, footers and layers. total does not count the draft report page.
layout(name, width, height)The active layout.
area(name, place, width, height, window, fill, surface)The hosting area. window: a fixed area that hosts recipient; surface: the color behind the content.
marksdictionary | noneThe resolved marks of the layout.

In a draft, a missing required field (the invoice number, a name, an address, the sender's tax ID) arrives as an inline marker in the same field, so every theme shows it.

Body Views​

PartFields
line-items, items-table, totals, notestotals.rows: an array of (kind, label, value: (value, text), emphasis, rate, name, marker, payable) in the legal order of the tax mode, with 0 % tax rows removed. kind is one of "subtotal", "discount", "surcharge", "net-total", "tax", "total", "prepayment", "amount-due". totals.payable: the row the recipient pays. exemption-notes: the notes below the line items, see Exemption Notes. Also items, entries, taxes, discounts, surcharges, prepayments, total, layout-information, tax-mode, tax-exempt-small-biz; notes also gets required.
bank-detailsiban: (value, text, valid), the text grouped in fours; payment-reference; show-reference; qr: none or (size) => content (EUR only, black on white, at least 20 mm, never for an invalid IBAN; a size given to bank-details(qr-code: ..) wins over the argument): the EPC-QR code, or in a draft and with zugferd-errors: "report" a placeholder that names why it cannot be generated; sender: (name, bank, iban, iban-valid, bic), where name is the account holder (the name of bank-details, else the payee of the invoice, not on a credit note, else the sender's name on one line, or the recipient's when the sender pays) and iban and bic are without spaces and in upper case; bank and bic are "" if not given, as is name with bank-details(name: none), and the built-in parts then leave the line out; qr-code: (size, display, payload, problems), the fields of the code (the arguments of epc-qr-code of the sepay package) or why it cannot be generated; report-problems; payment-amount; reference; text.
payment-termsamount: (value, text); amount-kind: "total" or "amount-due" (after prepayments); deadline: the deadline phrase; payment-means: the kind of payment means the sentence is for ("transfer", "direct-debit", "card" or none); discounts: the cash discounts with days, percent, basis and the printed note; days; date; total. The component sets the payment sentence of the locale (ctx.locale.strings.payment.text, text-due) to the one of the payment means, of a credit note, and with the notes of the cash discounts, so a part that prints it states them as well.
payment-meanskind: "direct-debit", "card" or "paid"; text: the sentence to print first, e.g. that the invoice was paid, or none; details: the details in order, each (label, value), an identifier with valid as well (false for an invalid one, which is a data issue of the invoice); report-problems: whether an invalid identifier is marked where it is printed (zugferd-errors: "report"; a draft lists it in its report instead).
zugferd-reportThe result of the e-invoice: profile (id, name, automatic, skipped) and diagnostics, errors first, each with level, rule, field, message and hint.
signaturename, signature.

A replacement for the payment sentence, built from the view:

#let pay-card(ctx, view) = {
let fill = ctx.theme.tokens.colors.accent.lighten(88%)
block(fill: fill, inset: 1em, width: 100%)[
Please pay *#view.amount.text* #view.deadline.
]
}

#show: invoice.with(
locale: locale.en-de,
theme: theme.corporate.with(theme.custom.part("payment-terms", pay-card)),
// sender: .., recipient: .., invoice-nr: ..
)

Exemption Notes​

exemption-notes lists the legal notes below the line items that give the reason for an exemption (exemption grounds, reverse charge, the small business clause), in the order to print them, followed by the notes of the invoice (invoice(notes: ..)). Each note is (kind, marker, body):

KeyDescription
kind"small-business" for the clause of tax-exempt-small-biz, "grounds" for an exemption ground, "note" for a note of the invoice.
markerThe marker ("*", "**", ...) that links the note to the VAT line of its category or to its items, none if neither of them shows it.
bodyThe text of the note.

The VAT lines carry the same markers (marker of each entry of taxes, and of the tax rows of totals.rows), as do the items of a VAT category with several exemption grounds (tax.marker of each entry of items). With exemption notes, the built-in notes part leaves out the standard tax statement (e.g. "All items are excl. 19% Tax."), and it prints every note even with line-items(show-information: false), which only hides what the items share: the law requires the exemption notes on the invoice, and the e-invoice states them and the notes (BT-120, BT-22). A notes part of your own should print them as well.

What the Theme Prints​

The e-invoice checks that the printed invoice shows the seller's tax number or VAT identifier (IP-PRINT-03) and the date of the supply (IP-PERIOD-03) the XML states, see Printed Details. invoice-pro reads what a theme prints from its layout: the reference signs if an area hosts references or reference-list, the extra of the sender if one hosts sender-details or contact, the extra of the recipient with recipient or return-address, and the seller's VAT ID and tax number with registration. A theme whose areas host no references, such as theme.plain, is not checked. A part keeps its meaning when a look or you replace or wrap its renderer, but content cells, function cells, replaced or wrapped renderers, custom parts and stationery may show more than their data, so a tax number that none of the known places shows is then not reported as missing.

Custom Parts and Areas​

An area's parts may hold part names, content and functions (ctx, view) => content. Content is evaluated with the invoice data, so info motifs work in it:

#show: invoice.with(
theme: theme.classic.with(
layout: theme.layout.us-letter-10,
theme.custom.area("footer", arrange: (columns: (1fr, 1fr, 1fr)), parts: (
[*Remit to:* #info.sender.name, PO Box 12, Austin TX],
[billing\@acme.com],
"registration",
)),
),
// sender: .., recipient: .., invoice-nr: ..
)

A named custom part needs a package prefix and must be hosted by an area. A new area needs a place:

#let paid-stamp(ctx, view) = {
let accent = ctx.theme.tokens.colors.accent
rotate(-12deg, block(
stroke: 2pt + accent,
inset: 6pt,
radius: ctx.theme.tokens.radii.medium,
text(size: 18pt, weight: "bold", fill: accent)[PAID],
))
}

#show: invoice.with(
theme: theme.classic.with({
import theme.custom: *
part("acme/paid", paid-stamp)
area(
"stamp",
place: "foreground",
pages: "first",
left: 140mm,
top: 110mm,
parts: ("acme/paid",),
)
colors(accent: rgb("#15803d"))
checks(pairs: (paid-stamp: t => (t.colors.accent, t.colors.background)))
}),
// sender: .., recipient: .., invoice-nr: ..
)

A part name without a prefix, or a registered part that no area hosts, stops the build with a message that says so.

Built-in Renderers: theme.parts​

theme.parts holds every built-in renderer as a function, for example theme.parts.totals(ctx, view). Call one inside your own part to start from the default output and change it (the "eject" in the first example on this page). The module contains logo, sender, sender-details, return-address, recipient, references, reference-list, title, company, contact, registration, bank-account, page-number, continuation, marks, qr-bill, line-items, items-table, totals, notes, bank-details, payment-terms, payment-means, signature and zugferd-report.

The preset looks replace many of these renderers. theme.parts always holds the renderers of classic.

Scoped Overrides: themed​

warning

themed is experimental.

themed(..patches)[body] re-themes a part of the body with the same patches as theme.custom. Derived tokens re-derive inside the scope.

#line-items[
#group([Phase 1])[#item([Concept], price: 1800)]
#themed(theme.custom.row(fill: rgb("#fef3c7")))[
#group([Phase 2 (optional)])[
#item([Artwork], price: 650)
#item([Print], price: 240)
]
]
]

#themed({
import theme.custom: *
colors(primary: rgb("#b91c1c")) // the tint re-derives inside the scope
wrap("bank-details", (ctx, view, inner) => block(
fill: ctx.theme.tokens.colors.tint,
inset: 8pt,
inner(ctx, view),
))
})[#bank-details(bank: "Hamburger Sparkasse", iban: "DE75512108001245126199")]
Allowed in themedRejected (panics)
tokens, options, checks, body parts, row(fill:)layout patches, frame parts, and the frame options logo, title, page-number, continuation: the page frame is drawn once per document

theme.custom.row(fill:) is read by the group, item and bundle components inside the scope; in nested scopes, the innermost wins. Issues found in a scope, such as a contrast problem, follow the validation level; in a draft their ids start with themed/.

Theme Packages​

A theme package ships a layout dictionary, prefixed parts and patch dictionaries. It imports nothing from invoice-pro, so it works with any invoice-pro version that supports the schema it uses. A wrap is written as the dictionary ("__invoice-pro-wrap__": wrapper), which is what theme.custom.wrap produces.

// @preview/acme-theme: lib.typ - no invoice-pro import
#let sidebar-a5 = (
name: "acme-sidebar-a5",
paper: "a5",
flipped: true,
marks: none,
margin: (top: 12mm, left: 72mm, right: 12mm), // bottom: computed from the footer
areas: (
rail: (
place: "background",
pages: "all",
left: 0mm,
top: 0mm,
width: 60mm,
height: 100%,
stationery: true,
fill: t => t.colors.primary,
inset: 8mm,
text: (fill: t => t.colors.on-primary, size: 8.5pt),
parts: ("acme/rail", "sender"),
),
title: (place: "before", parts: ("title",)),
address: (place: "before", parts: ("recipient",)),
page-number: (place: "footer", parts: ("page-number",), align: right),
footer: (place: "footer", parts: ("registration",)),
),
)

#let rail(ctx, view) = text(size: 16pt, weight: "bold")[ACME]

#let patch = (
parts: (
"acme/rail": rail,
signature: (
"__invoice-pro-wrap__": (ctx, view, inner) => {
inner(ctx, view)
text(size: 7pt)[Digitally issued.]
},
),
),
tokens: (colors: (primary: rgb("#7c3aed")), sizes: (body: 9pt)),
options: (items-table: (zebra: (none, none)), totals: (width: 60%)),
)

Users combine it with any preset:

#import "@preview/invoice-pro:0.6.0": *
#import "@preview/acme-theme:0.1.0" as acme

#show: invoice.with(
theme: theme.classic.with(acme.patch, layout: acme.sidebar-a5),
// sender: .., recipient: .., invoice-nr: ..
)

The package lists only the areas it uses; the standard areas it leaves out become empty stubs. Its CI can check the theme against the published invoice-pro with theme.resolve:

#let _ = theme.resolve(theme.classic.with(acme.patch, layout: acme.sidebar-a5))

Testing a Theme: theme.resolve​

theme.resolve(theme, env: .., validation: ..) evaluates a lazy theme outside an invoice, for unit tests and package CI.

KeyTypeDescription
themefunctionA lazy theme, such as theme.classic.with(..).
envdictionaryThe document environment. Defaults to (kind: "invoice", lang: "de", region: "de", e-invoice: none); region selects the layout for layout: auto.
validationnone | "draft" | "strict""strict" (default) panics when the theme has issues, so a CI job fails. "draft" and none return them in issues. --input invoice-pro-validation overrides it.

It returns the resolved theme, the same dictionary parts see as ctx.theme:

KeyContent
meta(name, schema): the preset name and the schema version.
envThe environment the theme was resolved for.
layoutThe resolved layout, with every area as a full record.
partsPart name → renderer (or none).
tokensAll 30 tokens, resolved.
optionsAll options, resolved.
checksThe check settings.
requirementsThe roles and parts the document kind requires.
issuesThe theme's own findings, such as contrast problems or a missing role.
unread-optionsOption groups you changed that no active built-in part reads.
#let company = theme.classic.with(
theme.custom.brand(color: rgb("#0f766e")),
theme.custom.checks(min-contrast: 4.5),
)

#let t = theme.resolve(company)
#assert.eq(t.tokens.colors.primary, rgb("#0f766e"))
#assert.eq(t.layout.name, "din-5008-a")
#assert.eq(t.issues, ())

#let us = theme.resolve(company, env: (
kind: "invoice",
lang: "en",
region: "us",
e-invoice: none,
))
#assert.eq(us.layout.name, "us-letter-10")