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
113 lines
3.3 KiB
Typst
113 lines
3.3 KiB
Typst
// 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(),
|
||
)
|