Files
wanderbooks/crates/ledger-import/src/pdf.rs
T
swaitsandClaude Opus 5.5 12308fe3e9 gui: Save PDF on every report; short and narrow windows hold up
Reports get a Save PDF button beside Copy for a spreadsheet. It typesets
the report as it's set up on screen (range, months/quarters/years,
comparison) through ledger-import's Typst statement template, the same
one the CPA package uses, in the background, writes it to the Downloads
folder (never the books: the viewer stays read-only), and opens it.

- ledger-import: Statement.percent_from marks percentage columns
  (comparison %, % of income), typeset as 12.5% with no dollar sign;
  wide statements turn the page, and past seven columns set smaller so
  a year of months fits; statement() is public for other callers.
- Compared columns that read the same (Jan 1 - Oct 12 vs a year
  earlier) now say which year, on screen and on paper.
- Short windows: the sidebar's and rail's links scroll above the status
  box instead of under it. The search palette fits the window.
- A test typesets every table-backed report from the demo books,
  plain, by month, and compared (PDF_DIR keeps the files).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-12 20:14:35 -06:00

198 lines
7.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! PDF financial statements, typeset with Typst.
//!
//! [`crate::report`] lays a statement out as rows; this module hands those
//! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and
//! renders the PDF. Amounts arrive already split by
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
//! and labels are data, never markup. The font (Inter, SIL OFL 1.1; see
//! `fonts/Inter-LICENSE.txt`) is bundled, so output doesn't depend on
//! what's installed, and it is deterministic: the same statement and
//! report metadata render to the same bytes.
use crate::report::{ReportMeta, RowKind, Statement, amount_parts};
use std::sync::OnceLock;
use thiserror::Error;
use typst::foundations::{Dict, IntoValue};
use typst_as_lib::{TypstEngine, TypstTemplateMainFile};
use typst_layout::PagedDocument;
/// PDF rendering errors.
#[derive(Debug, Error)]
pub enum PdfError {
/// Typst failed to compile the statement.
#[error("pdf typesetting failed: {0}")]
Compile(String),
/// The PDF writer failed.
#[error("pdf save failed: {0}")]
Save(String),
}
static TEMPLATE: &str = include_str!("../templates/statement.typ");
static INTER: [&[u8]; 3] = [
include_bytes!("../fonts/Inter-Regular.ttf"),
include_bytes!("../fonts/Inter-SemiBold.ttf"),
include_bytes!("../fonts/Inter-Bold.ttf"),
];
/// The engine, built once: parsing the bundled fonts is the expensive part.
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
static ENGINE: OnceLock<TypstEngine<TypstTemplateMainFile>> = OnceLock::new();
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(INTER)
.build()
})
}
/// The template's input: the statement with every amount split for
/// alignment.
pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
let kind = |k: RowKind| match k {
RowKind::Section => "section",
RowKind::Subheading => "subheading",
RowKind::Line => "line",
RowKind::Subtotal => "subtotal",
RowKind::Total => "total",
RowKind::Spacer => "spacer",
};
serde_json::json!({
"company": meta.company,
"title": st.title,
"period": st.period,
"footer": meta.footer(),
"columns": st.columns,
"rows": st.rows.iter().map(|r| serde_json::json!({
"kind": kind(r.kind),
"label": r.label,
"rule": r.rule_above,
"currency": r.currency,
"amounts": r.amounts.iter().enumerate().map(|(i, a)| a.map(|a| {
if st.percent_from.is_some_and(|f| i >= f) {
let a = a.round_dp(1);
let negative = a.is_sign_negative() && !a.is_zero();
return serde_json::json!({
"digits": format!("{}%", a.abs().normalize()),
"negative": negative,
"percent": true,
});
}
let p = amount_parts(a);
serde_json::json!({ "digits": p.digits, "negative": p.negative })
})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
})
}
/// Typeset a statement into a document (pages, before PDF encoding).
fn typeset(st: &Statement, meta: &ReportMeta) -> Result<PagedDocument, PdfError> {
let mut inputs = Dict::new();
inputs.insert(
"data".into(),
template_data(st, meta).to_string().into_value(),
);
engine()
.compile_with_input(inputs)
.output
.map_err(|e| PdfError::Compile(format!("{e:?}")))
}
/// Render a laid-out statement (see [`crate::report`]) as a PDF.
pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
let doc = typeset(st, meta)?;
typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default())
.map_err(|e| PdfError::Save(format!("{e:?}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::{Row, profit_and_loss};
use chrono::NaiveDate;
use ledger_core::reports::{ProfitAndLossStatement, StatementLine};
use rust_decimal::Decimal;
fn meta() -> ReportMeta {
ReportMeta {
company: "Test Co".to_string(),
basis: "Accrual".to_string(),
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
journal_seq: 42,
}
}
fn pl() -> ProfitAndLossStatement {
let line = |code: &str, name: &str, cents| StatementLine {
code: code.to_string(),
name: name.to_string(),
amount: Decimal::new(cents, 2),
};
ProfitAndLossStatement {
start: NaiveDate::from_ymd_opt(2026, 10, 4).unwrap(),
end: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
revenue: vec![],
expenses: vec![
line("6000", "Organizational Costs", 5900),
line("6400", "Memberships & Licenses", 10638),
],
total_revenue: Decimal::ZERO,
total_expenses: Decimal::new(16538, 2),
}
}
/// The exact data the template typesets: hand-checked, like a golden.
#[test]
fn template_data_is_exact() {
let amt = |digits: &str, negative: bool| serde_json::json!([{"digits": digits, "negative": negative}]);
let data = template_data(&profit_and_loss(&pl()), &meta());
assert_eq!(
data,
serde_json::json!({
"company": "Test Co",
"title": "Profit and Loss",
"period": "October 4 – October 11, 2026",
"footer": "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 42",
"columns": ["Total"],
"rows": [
{"kind": "section", "label": "Revenue", "rule": false, "currency": false, "amounts": []},
{"kind": "subtotal", "label": "Total revenue", "rule": false, "currency": true, "amounts": amt("0.00", false)},
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
{"kind": "section", "label": "Expenses", "rule": false, "currency": false, "amounts": []},
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "currency": false, "amounts": amt("59.00", false)},
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "currency": false, "amounts": amt("106.38", false)},
{"kind": "subtotal", "label": "Total expenses", "rule": true, "currency": true, "amounts": amt("165.38", false)},
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
{"kind": "total", "label": "Net income (loss)", "rule": true, "currency": true, "amounts": amt("165.38", true)},
],
})
);
}
#[test]
fn renders_a_pdf_deterministically() {
let st = profit_and_loss(&pl());
let a = statement_pdf(&st, &meta()).unwrap();
assert!(a.starts_with(b"%PDF"));
assert_eq!(a, statement_pdf(&st, &meta()).unwrap());
}
/// Labels are data: Typst markup characters print literally, and a
/// statement too long for one page flows onto more instead of being cut.
#[test]
fn markup_is_inert_and_long_statements_paginate() {
let mut st = profit_and_loss(&pl());
st.rows = (0..200)
.map(|i| Row {
kind: RowKind::Line,
label: format!("#{i} *not bold* $x$ <label> @ref [link] \\ #panic()"),
amounts: vec![Some(Decimal::new(i, 0))],
currency: false,
rule_above: false,
})
.collect();
let doc = typeset(&st, &meta()).unwrap();
assert!(doc.pages().len() > 1, "200 rows should span pages");
assert!(statement_pdf(&st, &meta()).unwrap().starts_with(b"%PDF"));
}
}