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:
1 parent
b5af21bb0e
commit
03212265ce
10 files changed
+2856
-545
No files matched your search
@@ -46,6 +46,14 @@ pub struct Row {
|
||||
pub label: String,
|
||||
/// Amount, when the row has one.
|
||||
pub amount: Option<Decimal>,
|
||||
/// Print a currency sign with the amount: US accounting style puts "$"
|
||||
/// on the first amount under each section heading and on every
|
||||
/// subtotal and total.
|
||||
pub currency: bool,
|
||||
/// Draw a single rule above the amount: the "sum of the above" line.
|
||||
/// Totals always have one; a subtotal only when amounts sit above it
|
||||
/// (an empty section gets no rule over nothing).
|
||||
pub rule_above: bool,
|
||||
}
|
||||
|
||||
/// A laid-out financial statement.
|
||||
@@ -64,6 +72,71 @@ fn row(kind: RowKind, label: impl Into<String>, amount: Option<Decimal>) -> Row
|
||||
kind,
|
||||
label: label.into(),
|
||||
amount,
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish a statement: mark which amounts carry the currency sign and which
|
||||
/// get a rule above.
|
||||
fn statement(title: &str, period: String, mut rows: Vec<Row>) -> Statement {
|
||||
let mut first_in_section = true;
|
||||
let mut above: Option<RowKind> = None;
|
||||
for r in &mut rows {
|
||||
r.rule_above = match r.kind {
|
||||
RowKind::Total => true,
|
||||
RowKind::Subtotal => matches!(above, Some(RowKind::Line | RowKind::Subtotal)),
|
||||
_ => false,
|
||||
};
|
||||
if r.kind != RowKind::Spacer {
|
||||
above = Some(r.kind);
|
||||
}
|
||||
match r.kind {
|
||||
RowKind::Section => first_in_section = true,
|
||||
RowKind::Subtotal | RowKind::Total => r.currency = true,
|
||||
RowKind::Line if r.amount.is_some() && first_in_section => {
|
||||
r.currency = true;
|
||||
first_in_section = false;
|
||||
}
|
||||
RowKind::Line | RowKind::Spacer => {}
|
||||
}
|
||||
}
|
||||
Statement {
|
||||
title: title.to_string(),
|
||||
period,
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
/// An amount split for column alignment: the digits, and whether it is a
|
||||
/// negative (printed in parentheses) or zero (printed as a dash). Renderers
|
||||
/// right-align `digits` and reserve the closing parenthesis's width on every
|
||||
/// row, so decimal points line up whether or not a value is negative.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AmountParts {
|
||||
/// "1,234.56" — absolute value, grouped, two decimals. Empty for zero.
|
||||
pub digits: String,
|
||||
/// Below zero.
|
||||
pub negative: bool,
|
||||
/// Exactly zero (after rounding to cents).
|
||||
pub zero: bool,
|
||||
}
|
||||
|
||||
/// Split an amount for aligned accounting display. Exact: no floats.
|
||||
pub fn amount_parts(d: Decimal) -> AmountParts {
|
||||
let rounded = d.round_dp(2);
|
||||
if rounded.is_zero() {
|
||||
return AmountParts {
|
||||
digits: String::new(),
|
||||
negative: false,
|
||||
zero: true,
|
||||
};
|
||||
}
|
||||
let text = format_amount(rounded.abs());
|
||||
AmountParts {
|
||||
digits: text,
|
||||
negative: rounded.is_sign_negative(),
|
||||
zero: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +183,7 @@ pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement {
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(row(RowKind::Total, "Net income", Some(pl.net_income())));
|
||||
Statement {
|
||||
title: "Profit and Loss".to_string(),
|
||||
period: period_label(pl.start, pl.end),
|
||||
rows,
|
||||
}
|
||||
statement("Profit and Loss", period_label(pl.start, pl.end), rows)
|
||||
}
|
||||
|
||||
/// Lay out a balance sheet.
|
||||
@@ -153,11 +222,11 @@ pub fn balance_sheet(bs: &BalanceSheetStatement) -> Statement {
|
||||
"Total liabilities and equity",
|
||||
Some(bs.total_liabilities + bs.total_equity),
|
||||
));
|
||||
Statement {
|
||||
title: "Balance Sheet".to_string(),
|
||||
period: format!("As of {}", long_date(bs.as_of)),
|
||||
statement(
|
||||
"Balance Sheet",
|
||||
format!("As of {}", long_date(bs.as_of)),
|
||||
rows,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Lay out a cash flow statement (indirect method).
|
||||
@@ -195,7 +264,7 @@ pub fn cash_flow(cf: &CashFlowStatement) -> Statement {
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(row(
|
||||
RowKind::Total,
|
||||
RowKind::Subtotal,
|
||||
"Net change in cash",
|
||||
Some(cf.net_change_in_cash),
|
||||
));
|
||||
@@ -209,11 +278,11 @@ pub fn cash_flow(cf: &CashFlowStatement) -> Statement {
|
||||
"Cash at end of period",
|
||||
Some(cf.cash_ending),
|
||||
));
|
||||
Statement {
|
||||
title: "Statement of Cash Flows".to_string(),
|
||||
period: period_label(cf.start, cf.end),
|
||||
statement(
|
||||
"Statement of Cash Flows",
|
||||
period_label(cf.start, cf.end),
|
||||
rows,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// "October 11, 2026".
|
||||
@@ -272,6 +341,49 @@ mod tests {
|
||||
assert_eq!(format_amount(dec("-100000")), "(100,000.00)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amounts_split_for_alignment() {
|
||||
let p = amount_parts(dec("-1234.5"));
|
||||
assert_eq!(
|
||||
(p.digits.as_str(), p.negative, p.zero),
|
||||
("1,234.50", true, false)
|
||||
);
|
||||
assert!(amount_parts(dec("0.004")).zero);
|
||||
assert_eq!(amount_parts(dec("207.68")).digits, "207.68");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn currency_signs_go_on_first_lines_and_totals() {
|
||||
let rows = vec![
|
||||
section("Assets"),
|
||||
row(RowKind::Line, "a", Some(dec("1"))),
|
||||
row(RowKind::Line, "b", Some(dec("2"))),
|
||||
row(RowKind::Subtotal, "t", Some(dec("3"))),
|
||||
spacer(),
|
||||
section("Equity"),
|
||||
row(RowKind::Line, "c", Some(dec("3"))),
|
||||
row(RowKind::Total, "T", Some(dec("3"))),
|
||||
];
|
||||
let st = statement("X", String::new(), rows);
|
||||
let signs: Vec<bool> = st.rows.iter().map(|r| r.currency).collect();
|
||||
assert_eq!(signs, [false, true, false, true, false, false, true, true]);
|
||||
let rules: Vec<bool> = st.rows.iter().map(|r| r.rule_above).collect();
|
||||
assert_eq!(
|
||||
rules,
|
||||
[false, false, false, true, false, false, false, true]
|
||||
);
|
||||
// A subtotal straight under its heading (empty section): no rule.
|
||||
let empty = statement(
|
||||
"Y",
|
||||
String::new(),
|
||||
vec![
|
||||
section("Investing"),
|
||||
row(RowKind::Subtotal, "t", Some(dec("0"))),
|
||||
],
|
||||
);
|
||||
assert!(!empty.rows[1].rule_above);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periods_read_like_english() {
|
||||
let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
|
||||
|
||||
Reference in new issue
Block a user