Files
wanderbooks/crates/ledger-import/templates/statement.typ
T
swaitsandClaude Opus 5.5 03212265ce feat: Typst-typeset statements in US accounting format
The printpdf statements padded columns with spaces in a proportional font
(amounts didn't line up), had no company name, printed negatives as
"-207.68", and put everything on one page (a long statement was silently
cut off past ~70 lines). Now:

- PDFs are typeset by Typst from one template,
  ledger-import/templates/statement.typ. The laid-out statement goes in as
  JSON data (sys.inputs), so account names are never parsed as markup.
  Fonts are bundled (typst-assets: Libertinus Serif), so output doesn't
  depend on the machine and is byte-for-byte deterministic. Statements
  paginate, with the company/title repeated on later pages and "Page n of
  m" plus "Prepared from journal through seq N" in the footer.
- US accounting presentation, decided once in ledger-import::report and
  applied by both PDF and XLSX: tabular figures; "$" on each section's
  first amount and on every subtotal and total, pinned to the column's
  left edge; negatives in parentheses with the closing paren hanging (its
  width reserved on every row, so decimal points align); zero as a dash;
  a single rule above sums (none over an empty section) and a double rule
  under statement totals; amounts never bold, because bold figures are
  wider and would leave the decimal column. XLSX uses Excel's own
  Accounting formats for the same result.
- Cash flow: "Net change in cash" is a subtotal; only "Cash at end of
  period" takes the double rule.
- Core fix: cash_flow walked the chart in HashMap order, so the order of
  working-capital and financing lines could change between runs. Lines
  now follow account code. Test: five owners, twenty runs, always
  3000/3001/3003/3005/3009; fails without the sort.

New dependencies (ledger-import): typst, typst-layout, typst-pdf,
typst-assets (fonts), typst-as-lib (World/font plumbing), serde_json (the
template's data). Removed: printpdf. Release ledger-mcp grows by the
bundled fonts.

Tests: exact template data for a hand-built P&L (like a golden); same
statement renders to identical bytes; 200 rows of Typst markup
characters render literally and span several pages; currency-sign and
rule placement; amount splitting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:23:01 -07:00

113 lines
3.3 KiB
Typst
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// A financial statement: P&L, balance sheet, or cash flow.
//
// Everything arrives as data in sys.inputs.data (JSON), already laid out by
// ledger-import::report, with amounts already split into exact parts. The
// template only typesets: no arithmetic happens here, and no label is ever
// parsed as markup.
//
// Shape: { company, title, period, footer,
// rows: [{ kind: "section" | "line" | "subtotal" | "total"
// | "spacer", label, rule (single rule above),
// amount: null | { digits: "1,234.56", negative, zero,
// currency } }] }
//
// Amounts follow US accounting presentation: tabular figures, "$" pinned
// to the left of the amount column on the first line of each section and on
// totals, negatives in parentheses with the closing parenthesis hanging
// (its width is reserved on every row, so decimal points always line up),
// and zero as a dash in the ones position. Amounts are never bold: bold
// figures are wider, which would knock totals out of the decimal column —
// the rules mark subtotals and totals instead.
#let d = json(bytes(sys.inputs.data))
#set document(title: d.company + " — " + d.title, author: d.company)
#set text(
font: "Libertinus Serif",
size: 10.5pt,
number-type: "lining",
number-width: "tabular",
)
#set page(
paper: "us-letter",
margin: (x: 1in, top: 0.9in, bottom: 0.8in),
header: context {
if counter(page).get().first() > 1 {
set text(8.5pt, fill: luma(110))
[#d.company — #d.title #h(1fr) #d.period]
}
},
footer: context {
set text(8pt, fill: luma(110))
[#d.footer #h(1fr) Page #counter(page).display() of #counter(page).final().first()]
},
)
#align(center)[
#text(15pt, weight: "bold", d.company) \
#v(2pt)
#text(12.5pt, d.title) \
#v(1pt)
#text(10pt, style: "italic", d.period)
]
#v(16pt)
// Two lines under a statement total, the accounting double rule, set
// clear of the descenders.
#let double-rule(body) = stack(
body,
v(2.4pt),
line(length: 100%, stroke: 0.5pt),
v(1.4pt),
line(length: 100%, stroke: 0.5pt),
)
#let amount(r) = {
let a = r.amount
if a == none { return [] }
let open = if a.negative { [(] } else { [] }
let close = if a.negative { [)] } else { hide[)] }
let body = if a.zero { [–#hide[.00]] } else { [#open#a.digits] }
grid(
columns: (auto, 1fr),
align: (left, right),
if a.currency [\$] else [],
[#body#close],
)
}
#table(
columns: (1fr, 9em),
stroke: none,
inset: (x: 0pt, y: 3.2pt),
align: (left + horizon, right + horizon),
..d
.rows
.map(r => {
if r.kind == "spacer" {
(table.cell(colspan: 2, v(3pt)),)
} else if r.kind == "section" {
(table.cell(colspan: 2, text(weight: "bold", r.label)),)
} else if r.kind == "line" {
(pad(left: 1.4em, r.label), amount(r))
} else if r.kind == "subtotal" {
(
text(weight: "bold", r.label),
table.cell(
stroke: if r.rule { (top: 0.5pt) } else { none },
amount(r),
),
)
} else {
(
align(top, text(weight: "bold", r.label)),
table.cell(
stroke: (top: 0.5pt),
double-rule(amount(r)),
),
)
}
})
.flatten(),
)