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 a0e40690d2
commit dc3dc8bd24
10 files changed
+2856 -545

No files matched your search

+21 -8
View File
@@ -40,7 +40,12 @@ impl From<rust_xlsxwriter::XlsxError> for XlsxError {
}
}
const MONEY: &str = "#,##0.00;(#,##0.00)";
/// Excel's Accounting format, with and without the currency symbol: "$"
/// pinned left, digits right, negatives in parentheses with the closing
/// parenthesis hanging (`_)` reserves its width on positives, so decimal
/// points line up), zero as a dash.
const ACCOUNTING: &str = r#"_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)"#;
const ACCOUNTING_USD: &str = r#"_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(@_)"#;
/// Company name, title, and period across the top; returns the next row.
fn header(
@@ -77,8 +82,11 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<
let mut workbook = Workbook::new();
let sheet = workbook.add_worksheet();
let bold = Format::new().set_bold();
let money = Format::new().set_num_format(MONEY);
let money_bold = money.clone().set_bold();
let money = Format::new().set_num_format(ACCOUNTING);
let usd = Format::new().set_num_format(ACCOUNTING_USD);
// Totals are marked by rules, not bold: bold figures are wider and would
// break the decimal column.
let usd_bold = usd.clone();
match report {
XlsxReport::TrialBalance(tb) => {
@@ -95,7 +103,10 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<
sheet.write_number_with_format(row, 3, dec_to_f64(line.credit), &money)?;
row += 1;
}
let total = money_bold.clone().set_border_top(FormatBorder::Thin);
let total = usd_bold
.clone()
.set_border_top(FormatBorder::Thin)
.set_border_bottom(FormatBorder::Double);
sheet.write_string_with_format(row, 1, "Total", &bold)?;
sheet.write_number_with_format(row, 2, dec_to_f64(tb.total_debits), &total)?;
sheet.write_number_with_format(row, 3, dec_to_f64(tb.total_credits), &total)?;
@@ -108,8 +119,8 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<
sheet.set_name(&st.title)?;
let mut row = header(sheet, meta, &st.title, Some(&st.period))?;
let line_label = Format::new().set_indent(1);
let subtotal = money_bold.clone().set_border_top(FormatBorder::Thin);
let total = money_bold
let subtotal = usd_bold.clone().set_border_top(FormatBorder::Thin);
let total = usd_bold
.clone()
.set_border_top(FormatBorder::Thin)
.set_border_bottom(FormatBorder::Double);
@@ -123,13 +134,15 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<
RowKind::Line => {
sheet.write_string_with_format(row, 0, &r.label, &line_label)?;
if let Some(a) = amount {
sheet.write_number_with_format(row, 1, a, &money)?;
let fmt = if r.currency { &usd } else { &money };
sheet.write_number_with_format(row, 1, a, fmt)?;
}
}
RowKind::Subtotal => {
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
if let Some(a) = amount {
sheet.write_number_with_format(row, 1, a, &subtotal)?;
let fmt = if r.rule_above { &subtotal } else { &usd_bold };
sheet.write_number_with_format(row, 1, a, fmt)?;
}
}
RowKind::Total => {