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
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-12 20:14:35 -06:00
1 parent ea996f6e7e
commit 12308fe3e9
9 files changed
+444 -101

No files matched your search

+10 -1
View File
@@ -67,7 +67,16 @@ pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
"label": r.label,
"rule": r.rule_above,
"currency": r.currency,
"amounts": r.amounts.iter().map(|a| a.map(|a| {
"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<_>>(),
+5 -1
View File
@@ -102,6 +102,9 @@ pub struct Statement {
pub columns: Vec<String>,
/// The rows, top to bottom.
pub rows: Vec<Row>,
/// Columns from this index on hold percentages ("12.5%"), not money:
/// a comparison's "%" column, a P&L's "% of income".
pub percent_from: Option<usize>,
}
fn row(kind: RowKind, label: impl Into<String>, amounts: Vec<Option<Decimal>>) -> Row {
@@ -131,7 +134,7 @@ fn spacer() -> Row {
}
/// Finish a statement: currency signs on sums, rules above them.
fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec<Row>) -> Statement {
pub fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec<Row>) -> Statement {
let mut above: Option<RowKind> = None;
for r in &mut rows {
r.currency = matches!(r.kind, RowKind::Subtotal | RowKind::Total);
@@ -149,6 +152,7 @@ fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec<Row>)
period,
columns: columns.iter().map(|c| c.to_string()).collect(),
rows,
percent_from: None,
}
}
+9 -4
View File
@@ -18,15 +18,20 @@
#let d = json(bytes(sys.inputs.data))
#let ncols = d.columns.len()
// Wide statements (by month, with comparisons) turn the page and, past
// seven columns, set smaller and tighter so a year of months fits.
#let wide = ncols > 4
#let dense = ncols > 7
#let ink = luma(25)
#let quiet = luma(110)
#let hair = 0.5pt + luma(160)
#let firm = 0.6pt + ink
#set document(title: d.company + " — " + d.title, author: d.company)
#set text(font: "Inter", size: 9pt, fill: ink, number-width: "tabular")
#set text(font: "Inter", size: if dense { 7pt } else { 9pt }, fill: ink, number-width: "tabular")
#set page(
paper: "us-letter",
flipped: wide,
margin: (x: 0.75in, top: 0.75in, bottom: 0.75in),
header: context {
if counter(page).get().first() > 1 {
@@ -58,7 +63,7 @@
if a == none { return [] }
let open = if a.negative { [(] } else { [] }
let close = if a.negative { [)] } else { hide[)] }
let sign = if currency [\$] else []
let sign = if currency and a.at("percent", default: false) == false [\$] else []
[#open#sign#a.digits#close]
}
@@ -76,7 +81,7 @@
}
#table(
columns: (1fr,) + (8em,) * ncols,
columns: (1fr,) + (if dense { 6.6em } else { 8em },) * ncols,
stroke: none,
inset: (x: 4pt, y: 3.6pt),
align: (left + horizon,) + (right + horizon,) * ncols,
@@ -84,7 +89,7 @@
table.cell(stroke: (top: firm, bottom: hair), []),
..d.columns.map(c => table.cell(
stroke: (top: firm, bottom: hair),
text(7.5pt, weight: "bold", tracking: 0.04em, upper(c)),
text(if dense { 6pt } else { 7.5pt }, weight: "bold", tracking: 0.04em, upper(c)),
)),
),
..d