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
a0e40690d2
commit
dc3dc8bd24
10 files changed
+2856
-545
No files matched your search
@@ -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 => {
|
||||
|
||||
Reference in new issue
Block a user