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
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-11 19:23:01 -07:00
1 parent b5af21bb0e
commit 03212265ce
10 files changed
+2856 -545

No files matched your search

@@ -0,0 +1,112 @@
// 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(),
)