Files
wanderbooks/crates/ledger-import/src/pdf.rs
T
swaitsandClaude Opus 5.5 eb4329bb2a
check / check (push) Canceled after 0s
P3: the cash flow statement by ASC 230 (defect 1, D-35, D-42)
Direct method with the operating reconciliation and noncash schedules; it
foots by construction (Lean and Kani). Every non-cash account belongs to
one section; reversals net; a member or lender paying for the company is
cash borrowed and spent (D-42, constructive receipt and disbursement);
property a member contributes stays noncash. The bookspec seed: one loader
checks every example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 09:35:32 -06:00

337 lines
14 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 fonts are 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. Inter sets
//! the text; the Noto fallbacks ([`crate::fonts`]) draw what Inter can't:
//! Chinese, Japanese, Korean, Arabic, Hebrew, Thai and Devanagari, so a
//! vendor or an account named in them prints as written, right-to-left
//! where it is written so, never as empty boxes. (Emoji have no font
//! here: an emoji prints as an empty box.)
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),
/// No labels to print.
#[error("no labels to print")]
NoLabels,
/// The skip would start past the first sheet.
#[error("skip {skip} is out of range: a sheet has {per_sheet} labels, so skip must be 0 to {}", per_sheet - 1)]
SkipOutOfRange {
/// The requested skip.
skip: usize,
/// Labels per sheet.
per_sheet: usize,
},
/// A tag could not be encoded as a QR code (too long).
#[error("qr code failed: {0}")]
Qr(String),
}
static TEMPLATE: &str = include_str!("../templates/statement.typ");
/// 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(crate::fonts::typst_fonts())
.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::Detail => "detail",
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.
///
/// ```text
/// GOAL: a statement a CPA can print and file.
/// SOURCE: CONVENTION (layout); docs/spec/sources/irs-records.md (records
/// "capable of being processed", reproducible).
/// INVARIANTS:
/// 1. Deterministic: the same statement and metadata render to the same
/// bytes.
/// 2. Every label is data, never markup: a name holding Typst syntax
/// ("#panic()", "*", "$", "[") prints as text and NEVER runs.
/// 3. Every row reaches the page: template_data carries each row's label,
/// kind, and amounts split by amount_parts, in order.
/// 4. Every character prints as itself when a bundled font has it:
/// Chinese, Japanese, Korean, Arabic, Hebrew, Thai and Devanagari
/// from the Noto fallbacks (crate::fonts), Arabic and Hebrew right to
/// left, never an empty box.
/// ```
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, 3, 19).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, 3, 2).unwrap(),
end: NaiveDate::from_ymd_opt(2026, 3, 18).unwrap(),
revenue: vec![],
expenses: vec![
line("6000", "Organizational Costs", 7200),
line("6400", "Memberships & Licenses", 11840),
],
total_revenue: Decimal::ZERO,
total_expenses: Decimal::new(19040, 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": "March 2 – March 18, 2026",
"footer": "Accrual basis · Prepared Thursday, March 19, 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("72.00", false)},
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "currency": false, "amounts": amt("118.40", false)},
{"kind": "subtotal", "label": "Total expenses", "rule": true, "currency": true, "amounts": amt("190.40", false)},
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
{"kind": "total", "label": "Net income (loss)", "rule": true, "currency": true, "amounts": amt("190.40", 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"));
}
/// Each text run on the pages: its text, its font's family, and its
/// glyphs' ids in that font (0 is the font's empty box, "notdef").
fn runs(doc: &PagedDocument) -> Vec<(String, String, Vec<u16>)> {
use typst::layout::{Frame, FrameItem};
fn walk(frame: &Frame, out: &mut Vec<(String, String, Vec<u16>)>) {
for (_, item) in frame.items() {
match item {
FrameItem::Group(g) => walk(&g.frame, out),
FrameItem::Text(t) => out.push((
t.text.to_string(),
t.font.font().info().family.clone(),
t.glyphs.iter().map(|g| g.id).collect(),
)),
_ => {}
}
}
}
let mut out = Vec::new();
for p in doc.pages() {
walk(&p.frame, &mut out);
}
out
}
/// INVARIANT 4: a company, an account and a vendor named in Chinese,
/// Japanese and Korean print in the CJK fallback, every glyph a real
/// one; the rest stays Inter. (With Inter alone, every one of these
/// was glyph 0, an empty box.)
#[test]
fn chinese_japanese_and_korean_print_as_written() {
let mut m = meta();
m.company = "北京 Trading 株式会社".to_string();
let mut st = profit_and_loss(&pl());
st.rows.push(Row {
kind: RowKind::Line,
label: "6100 東京 서울 ありがとう ABC".into(),
amounts: vec![Some(Decimal::ONE)],
currency: false,
rule_above: false,
});
let runs = runs(&typeset(&st, &m).unwrap());
let mut seen = String::new();
for (text, family, glyphs) in &runs {
assert!(
!glyphs.contains(&0),
"{text:?} has an empty box in {family}"
);
let cjk = text.chars().any(|c| {
matches!(u32::from(c), 0x1100..=0x11FF | 0x3000..=0x9FFF | 0xAC00..=0xD7AF | 0xFF00..=0xFFEF)
});
let ascii = text.is_ascii();
if cjk {
assert_eq!(family, "Noto Sans CJK SC", "{text:?}");
seen.push_str(text);
} else if ascii {
assert_eq!(family, "Inter", "{text:?}");
}
}
for want in ["北京", "株式会社", "東京", "서울", "ありがとう", "ABC"] {
assert!(seen.contains(want), "{want} printed: {runs:?}");
}
}
/// INVARIANT 4 for the other bundled scripts: Arabic, Hebrew, Thai and
/// Devanagari each print in their own Noto fallback, every glyph a
/// real one (the right-to-left reading is checked on the PDF itself,
/// `tests/spec_import.rs` `pdf_prints_every_bundled_script_as_written`).
#[test]
fn arabic_hebrew_thai_and_devanagari_print_as_written() {
let mut m = meta();
m.company = "شركة Example".to_string();
let mut st = profit_and_loss(&pl());
for label in ["مكتب", "שלום", "สวัสดี", "नमस्ते"] {
st.rows.push(Row {
kind: RowKind::Line,
label: label.into(),
amounts: vec![Some(Decimal::ONE)],
currency: false,
rule_above: false,
});
}
let runs = runs(&typeset(&st, &m).unwrap());
let family_of = |c: char| match u32::from(c) {
0x0600..=0x06FF => Some("Noto Sans Arabic UI"),
0x0590..=0x05FF => Some("Noto Sans Hebrew"),
0x0E00..=0x0E7F => Some("Noto Sans Thai"),
0x0900..=0x097F => Some("Noto Sans Devanagari"),
_ => None,
};
for (text, family, glyphs) in &runs {
assert!(
!glyphs.contains(&0),
"{text:?} has an empty box in {family}"
);
if let Some(want) = text.chars().find_map(family_of) {
assert_eq!(family, want, "{text:?}");
}
}
// Every letter printed in its script's font.
for c in "شركةمكتبשלוםสวัสดีनमस्ते".chars() {
let want = family_of(c).unwrap();
assert!(
runs.iter().any(|(t, f, _)| f == want && t.contains(c)),
"{c} printed in {want}: {runs:?}"
);
}
}
}