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

+1
View File
@@ -14,6 +14,7 @@ path = "src/main.rs"
ledger-core = { path = "../ledger-core" } ledger-core = { path = "../ledger-core" }
ledger-store = { path = "../ledger-store" } ledger-store = { path = "../ledger-store" }
ledger-config = { path = "../ledger-config" } ledger-config = { path = "../ledger-config" }
ledger-import = { path = "../ledger-import" }
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11", "persistence"] } eframe = { version = "0.36", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11", "persistence"] }
egui = "0.36" egui = "0.36"
egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] } egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] }
+118 -90
View File
@@ -281,61 +281,74 @@ impl App {
ui.add_space(18.0); ui.add_space(18.0);
let current = self.nav.current.section(); let current = self.nav.current.section();
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0); let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
for s in Section::ALL { // Short windows: the links scroll above the status box
let on = s == current; // instead of the box drawing over them.
let (rect, resp) = let links_h = (ui.available_height() - SIDEBAR_FOOTER).max(64.0);
ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); egui::ScrollArea::vertical()
if on { .id_salt("sidebar_links")
ui.painter() .max_height(links_h)
.rect_filled(rect, CornerRadius::same(8), p.surface); .show(ui, |ui| {
ui.painter().rect_stroke( for s in Section::ALL {
rect, let on = s == current;
CornerRadius::same(8), let (rect, resp) = ui.allocate_exact_size(
Stroke::new(1.0, p.border), vec2(ui.available_width(), 32.0),
egui::StrokeKind::Inside, Sense::click(),
); );
} else if resp.hovered() { if on {
ui.painter() ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.hover); .rect_filled(rect, CornerRadius::same(8), p.surface);
} ui.painter().rect_stroke(
let color = if on { p.text } else { p.muted }; rect,
ui.painter().text( CornerRadius::same(8),
rect.left_center() + vec2(10.0, 0.0), Stroke::new(1.0, p.border),
egui::Align2::LEFT_CENTER, egui::StrokeKind::Inside,
s.icon(), );
egui::FontId::proportional(16.0), } else if resp.hovered() {
if on { p.accent } else { p.muted }, ui.painter()
); .rect_filled(rect, CornerRadius::same(8), p.hover);
ui.painter().text( }
rect.left_center() + vec2(36.0, 0.0), let color = if on { p.text } else { p.muted };
egui::Align2::LEFT_CENTER, ui.painter().text(
s.title(), rect.left_center() + vec2(10.0, 0.0),
if on { egui::Align2::LEFT_CENTER,
theme::semibold(13.5) s.icon(),
} else { egui::FontId::proportional(16.0),
theme::regular(13.5) if on { p.accent } else { p.muted },
}, );
color, ui.painter().text(
); rect.left_center() + vec2(36.0, 0.0),
if s == Section::Overview && attention > 0 { egui::Align2::LEFT_CENTER,
let badge = egui::Rect::from_center_size( s.title(),
rect.right_center() - vec2(18.0, 0.0), if on {
vec2(22.0, 18.0), theme::semibold(13.5)
); } else {
ui.painter() theme::regular(13.5)
.rect_filled(badge, CornerRadius::same(9), p.warning_soft); },
ui.painter().text( color,
badge.center(), );
egui::Align2::CENTER_CENTER, if s == Section::Overview && attention > 0 {
attention.to_string(), let badge = egui::Rect::from_center_size(
theme::semibold(11.0), rect.right_center() - vec2(18.0, 0.0),
p.warning, vec2(22.0, 18.0),
); );
} ui.painter().rect_filled(
if resp.clicked() { badge,
self.nav.go(s.home()); CornerRadius::same(9),
} p.warning_soft,
} );
ui.painter().text(
badge.center(),
egui::Align2::CENTER_CENTER,
attention.to_string(),
theme::semibold(11.0),
p.warning,
);
}
if resp.clicked() {
self.nav.go(s.home());
}
}
});
ui.with_layout(Layout::bottom_up(Align::Min), |ui| { ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
let label = if self.dark { let label = if self.dark {
format!("{} Light mode", icon::SUN) format!("{} Light mode", icon::SUN)
@@ -403,41 +416,52 @@ impl App {
ui.add_space(18.0); ui.add_space(18.0);
let current = self.nav.current.section(); let current = self.nav.current.section();
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0); let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
for s in Section::ALL { let links_h = (ui.available_height() - RAIL_FOOTER).max(72.0);
let on = s == current; egui::ScrollArea::vertical()
let (rect, resp) = .id_salt("rail_links")
ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); .max_height(links_h)
if on { .show(ui, |ui| {
ui.painter() for s in Section::ALL {
.rect_filled(rect, CornerRadius::same(8), p.surface); let on = s == current;
ui.painter().rect_stroke( let (rect, resp) = ui.allocate_exact_size(
rect, vec2(ui.available_width(), 36.0),
CornerRadius::same(8), Sense::click(),
Stroke::new(1.0, p.border), );
egui::StrokeKind::Inside, if on {
); ui.painter()
} else if resp.hovered() { .rect_filled(rect, CornerRadius::same(8), p.surface);
ui.painter() ui.painter().rect_stroke(
.rect_filled(rect, CornerRadius::same(8), p.hover); rect,
} CornerRadius::same(8),
ui.painter().text( Stroke::new(1.0, p.border),
rect.center(), egui::StrokeKind::Inside,
egui::Align2::CENTER_CENTER, );
s.icon(), } else if resp.hovered() {
egui::FontId::proportional(18.0), ui.painter()
if on { p.accent } else { p.muted }, .rect_filled(rect, CornerRadius::same(8), p.hover);
); }
let tip = if s == Section::Overview && attention > 0 { ui.painter().text(
ui.painter() rect.center(),
.circle_filled(rect.center() + vec2(9.0, -8.0), 4.0, p.warning); egui::Align2::CENTER_CENTER,
format!("{} · {attention} need attention", s.title()) s.icon(),
} else { egui::FontId::proportional(18.0),
s.title().to_string() if on { p.accent } else { p.muted },
}; );
if resp.on_hover_text(tip).clicked() { let tip = if s == Section::Overview && attention > 0 {
self.nav.go(s.home()); ui.painter().circle_filled(
} rect.center() + vec2(9.0, -8.0),
} 4.0,
p.warning,
);
format!("{} · {attention} need attention", s.title())
} else {
s.title().to_string()
};
if resp.on_hover_text(tip).clicked() {
self.nav.go(s.home());
}
}
});
ui.with_layout(Layout::bottom_up(Align::Center), |ui| { ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
let (glyph, tip) = if self.dark { let (glyph, tip) = if self.dark {
(icon::SUN, "Light mode") (icon::SUN, "Light mode")
@@ -616,6 +640,10 @@ impl App {
/// Width of the icon rail. /// Width of the icon rail.
const RAIL_W: f32 = 60.0; const RAIL_W: f32 = 60.0;
/// Height kept clear under the sidebar's links for the status box and the
/// theme toggle; the rail's equivalent.
const SIDEBAR_FOOTER: f32 = 172.0;
const RAIL_FOOTER: f32 = 84.0;
fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) { fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
let p = pal(ui.ctx()); let p = pal(ui.ctx());
+71
View File
@@ -0,0 +1,71 @@
//! Files the viewer makes for you (report PDFs). They go to the
//! Downloads folder, never into the books: the viewer stays read-only.
use std::path::{Path, PathBuf};
/// Where saved files go: `$XDG_DOWNLOAD_DIR`, else `~/Downloads`, else
/// the home directory.
pub fn downloads_dir() -> PathBuf {
if let Some(d) = std::env::var_os("XDG_DOWNLOAD_DIR").map(PathBuf::from)
&& d.is_dir()
{
return d;
}
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let d = home.join("Downloads");
if d.is_dir() { d } else { home }
}
/// `name` made safe as a file name on every platform.
pub fn file_name(name: &str, ext: &str) -> String {
let clean: String = name
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
c if c.is_control() => ' ',
c => c,
})
.collect();
format!("{}.{ext}", clean.trim())
}
/// Write `bytes` to the downloads folder as `name.ext` (replacing a file
/// of that name: the same report renders the same bytes) and open it with
/// the system's viewer.
pub fn save_and_open(name: &str, ext: &str, bytes: &[u8]) -> Result<PathBuf, String> {
let path = downloads_dir().join(file_name(name, ext));
std::fs::write(&path, bytes).map_err(|e| format!("{}: {e}", path.display()))?;
open(&path);
Ok(path)
}
/// Open a file with whatever the desktop uses for its type. Best effort:
/// the file is saved either way.
fn open(path: &Path) {
#[cfg(target_os = "macos")]
let cmd = std::process::Command::new("open").arg(path).spawn();
#[cfg(target_os = "windows")]
let cmd = std::process::Command::new("cmd")
.args(["/C", "start", ""])
.arg(path)
.spawn();
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let cmd = std::process::Command::new("xdg-open").arg(path).spawn();
let _ = cmd;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_names_are_safe() {
assert_eq!(
file_name("Acme LLC — Profit and loss — Jan 1/2026: draft", "pdf"),
"Acme LLC — Profit and loss — Jan 1-2026- draft.pdf"
);
}
}
+1
View File
@@ -9,6 +9,7 @@ pub mod books;
mod charts; mod charts;
pub mod data; pub mod data;
pub mod demo; pub mod demo;
mod export;
mod fmt; mod fmt;
pub mod nav; pub mod nav;
pub mod pages; pub mod pages;
+225 -3
View File
@@ -103,6 +103,32 @@ pub struct State {
pub range: Range, pub range: Range,
pub columns: Columns, pub columns: Columns,
pub compare: Compare, pub compare: Compare,
/// A PDF being typeset in the background.
pdf_job: Option<std::sync::mpsc::Receiver<Result<std::path::PathBuf, String>>>,
/// How the last PDF went, and when (to fade the message).
pdf_done: Option<(Result<std::path::PathBuf, String>, f64)>,
}
/// What a PDF of the report on screen needs besides the table.
struct PdfInfo {
title: String,
period: String,
meta: ledger_import::ReportMeta,
}
impl PdfInfo {
fn new(b: &Books, title: &str, period: &str) -> Self {
PdfInfo {
title: title.to_string(),
period: period.to_string(),
meta: ledger_import::ReportMeta {
company: b.company_name().to_string(),
basis: b.settings.company.basis.clone(),
prepared_on: b.today,
journal_seq: b.seq(),
},
}
}
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@@ -247,6 +273,49 @@ impl Table {
} }
} }
/// The table as a statement for the PDF typesetter: sections spaced
/// apart, sums ruled and signed, notes as italic subheadings. Detail
/// lines (the accounts under a tax line) don't carry over.
fn statement(&self, title: &str, period: &str) -> ledger_import::Statement {
use ledger_import::{Row as PdfRow, RowKind};
let mut rows = Vec::new();
for r in &self.rows {
let kind = match r.kind {
Kind::Section => RowKind::Section,
Kind::Line => RowKind::Line,
Kind::Subtotal => RowKind::Subtotal,
Kind::Grand => RowKind::Total,
Kind::Note => RowKind::Subheading,
};
if matches!(kind, RowKind::Section | RowKind::Subheading) && !rows.is_empty() {
rows.push(PdfRow {
kind: RowKind::Spacer,
label: String::new(),
amounts: Vec::new(),
currency: false,
rule_above: false,
});
}
// Icon glyphs (private use) aren't in the PDF's font.
let label: String = r
.label
.chars()
.filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c))
.collect();
rows.push(PdfRow {
kind,
label: label.trim().to_string(),
amounts: r.cells.iter().map(|c| c.value).collect(),
currency: false,
rule_above: false,
});
}
let columns: Vec<&str> = self.columns.iter().map(String::as_str).collect();
let mut st = ledger_import::report::statement(title, period.to_string(), &columns, rows);
st.percent_from = self.pct_from;
st
}
fn tsv(&self) -> String { fn tsv(&self) -> String {
let mut out = String::from("\t"); let mut out = String::from("\t");
out.push_str(&self.columns.join("\t")); out.push_str(&self.columns.join("\t"));
@@ -295,6 +364,11 @@ fn column_ranges(st: &State, b: &Books) -> Vec<(NaiveDate, NaiveDate, String)> {
let mut v = vec![(s, e, col_title(s, e, Columns::Total))]; let mut v = vec![(s, e, col_title(s, e, Columns::Total))];
if let Some((ps, pe)) = prior(st, s, e) { if let Some((ps, pe)) = prior(st, s, e) {
v.push((ps, pe, col_title(ps, pe, Columns::Total))); v.push((ps, pe, col_title(ps, pe, Columns::Total)));
// Same days a year apart read the same: say which year.
if v[0].2 == v[1].2 {
v[0].2 = format!("{}, {}", v[0].2, e.year());
v[1].2 = format!("{}, {}", v[1].2, pe.year());
}
} }
v v
} }
@@ -1087,6 +1161,7 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>, picke
} else { } else {
Compare::None Compare::None
}, },
..Default::default()
}; };
let cols = column_ranges(&state, b); let cols = column_ranges(&state, b);
let compare = state.compare != Compare::None; let compare = state.compare != Compare::None;
@@ -1190,19 +1265,55 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>, picke
} }
}; };
if let Some(t) = table { if let Some(t) = table {
render(ui, &t, shell); render(ui, &t, &PdfInfo::new(b, kind.title(), &subtitle), shell);
} }
if let Some(k) = pick { if let Some(k) = pick {
shell.go(Route::Reports(k)); shell.go(Route::Reports(k));
} }
} }
fn render(ui: &mut Ui, t: &Table, shell: &mut Shell<'_>) { fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) {
let p = pal(ui.ctx()); let p = pal(ui.ctx());
let tsv = t.tsv(); let tsv = t.tsv();
let mut go: Option<Drill> = None; let mut go: Option<Drill> = None;
let st = &mut shell.state.reports;
if let Some(rx) = &st.pdf_job {
if let Ok(done) = rx.try_recv() {
st.pdf_job = None;
st.pdf_done = Some((done, ui.input(|i| i.time)));
} else {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(100));
}
}
let mut save_pdf = false;
widgets::card(ui, |ui| { widgets::card(ui, |ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
let busy = st.pdf_job.is_some();
let label = if busy {
format!("{} Making PDF…", icon::HOURGLASS)
} else {
format!("{} Save PDF", icon::FILE_PDF)
};
if ui
.add_enabled(
!busy,
egui::Button::new(
RichText::new(label)
.font(theme::regular(12.0))
.color(p.muted),
)
.frame(false),
)
.on_hover_text(format!(
"Typesets this report as it's set up here and saves it to {}, then opens it",
crate::export::downloads_dir().display()
))
.clicked()
{
save_pdf = true;
}
ui.add_space(8.0);
if ui if ui
.add( .add(
egui::Button::new( egui::Button::new(
@@ -1218,6 +1329,31 @@ fn render(ui: &mut Ui, t: &Table, shell: &mut Shell<'_>) {
ui.ctx().copy_text(tsv.clone()); ui.ctx().copy_text(tsv.clone());
} }
}); });
if let Some((done, at)) = &st.pdf_done {
let now = ui.input(|i| i.time);
if now - at < 8.0 {
let (text, color) = match done {
Ok(path) => (
format!("{} Saved {}", icon::CHECK, path.display()),
p.positive,
),
Err(e) => (
format!("{} PDF failed: {e}", icon::WARNING_CIRCLE),
p.negative,
),
};
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
ui.add(
egui::Label::new(
RichText::new(text).font(theme::regular(11.5)).color(color),
)
.truncate(),
);
});
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(500));
}
}
let n = t.columns.len().max(1) as f32; let n = t.columns.len().max(1) as f32;
let base: f32 = if t.columns.len() > 6 { 112.0 } else { 136.0 }; let base: f32 = if t.columns.len() > 6 { 112.0 } else { 136.0 };
// Narrow, the amount columns give up their slack before the table // Narrow, the amount columns give up their slack before the table
@@ -1388,6 +1524,22 @@ fn render(ui: &mut Ui, t: &Table, shell: &mut Shell<'_>) {
} }
}); });
}); });
if save_pdf {
let statement = t.statement(&pdf.title, &pdf.period);
let meta = pdf.meta.clone();
let name = format!("{} — {} — {}", meta.company, pdf.title, pdf.period);
let (tx, rx) = std::sync::mpsc::channel();
let ctx = ui.ctx().clone();
std::thread::spawn(move || {
let done = ledger_import::statement_pdf(&statement, &meta)
.map_err(|e| e.to_string())
.and_then(|bytes| crate::export::save_and_open(&name, "pdf", &bytes));
let _ = tx.send(done);
ctx.request_repaint();
});
st.pdf_job = Some(rx);
st.pdf_done = None;
}
if let Some(d) = go { if let Some(d) = go {
shell.state.accounts.range = Some((d.start, d.end)); shell.state.accounts.range = Some((d.start, d.end));
shell.go(Route::Account(d.account)); shell.go(Route::Account(d.account));
@@ -1588,5 +1740,75 @@ fn vendor_1099(ui: &mut Ui, b: &Books, year: i32, shell: &mut Shell<'_>) {
if r.vendors.is_empty() { if r.vendors.is_empty() {
t.note("No vendors marked for a 1099 and none paid this year."); t.note("No vendors marked for a 1099 and none paid this year.");
} }
render(ui, &t, shell); let pdf = PdfInfo::new(
b,
ReportKind::Vendor1099.title(),
&format!("Calendar year {year}"),
);
render(ui, &t, &pdf, shell);
}
#[cfg(test)]
mod tests {
use super::*;
/// Every table-backed report typesets as a PDF, in its plain, by-month,
/// and compared layouts. Set `PDF_DIR` to keep the files for eyes.
#[test]
fn reports_typeset_as_pdf() {
let today = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
let dir = std::env::temp_dir().join(format!("ledger-gui-pdf-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
crate::demo::build(&dir, today).unwrap();
let b = crate::data::load_once(&crate::data::Source {
data_dir: dir.join("books-data"),
books_toml: Some(dir.join("wanderbooks.toml")),
today: Some(today),
})
.unwrap();
let ytd = |columns, compare| State {
range: Range::YearToDate,
columns,
compare,
..Default::default()
};
let (s, e) = Range::YearToDate.dates(&b);
let mut tables: Vec<(&str, Table)> = Vec::new();
let mut t = pnl(
&b,
&column_ranges(&ytd(Columns::Total, Compare::None), &b),
false,
);
t.add_share_of_income();
tables.push(("pnl", t));
let cols = column_ranges(&ytd(Columns::Month, Compare::None), &b);
tables.push(("pnl-months", pnl(&b, &cols, true)));
let mut t = pnl(
&b,
&column_ranges(&ytd(Columns::Total, Compare::PriorYear), &b),
false,
);
t.add_change();
tables.push(("pnl-vs-prior-year", t));
tables.push(("balance", balance_sheet(&b, &[(e, "Total".into())])));
let cols = column_ranges(&ytd(Columns::Total, Compare::None), &b);
tables.push(("cashflow", cash_flow_table(&b, &cols, false)));
tables.push(("trial", trial_balance(&b, e)));
tables.push(("equity", owners_equity(&b, s, e)));
tables.push(("tax", tax(&b, s, e)));
tables.push(("ar", aging(b.ledger.ar_aging(e), e)));
let keep = std::env::var_os("PDF_DIR").map(std::path::PathBuf::from);
for (name, t) in tables {
let info = PdfInfo::new(&b, name, "Jan 1 – Oct 12, 2026");
let bytes =
ledger_import::statement_pdf(&t.statement(&info.title, &info.period), &info.meta)
.unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(bytes.starts_with(b"%PDF"), "{name}");
if let Some(d) = &keep {
std::fs::create_dir_all(d).unwrap();
std::fs::write(d.join(format!("{name}.pdf")), bytes).unwrap();
}
}
let _ = std::fs::remove_dir_all(&dir);
}
} }
+4 -2
View File
@@ -71,7 +71,9 @@ impl Palette {
.inner_margin(Margin::same(10)), .inner_margin(Margin::same(10)),
) )
.show(ctx, |ui| { .show(ctx, |ui| {
ui.set_width(620.0); // Never wider or taller than the window allows.
let screen = ctx.content_rect().size();
ui.set_width(620.0_f32.min(screen.x - 48.0).max(200.0));
let edit = egui::TextEdit::singleline(&mut self.query) let edit = egui::TextEdit::singleline(&mut self.query)
.hint_text("Search entries, accounts, vendors, documents, reports…") .hint_text("Search entries, accounts, vendors, documents, reports…")
.font(theme::regular(16.0)) .font(theme::regular(16.0))
@@ -89,7 +91,7 @@ impl Palette {
crate::widgets::rule(ui); crate::widgets::rule(ui);
ui.add_space(4.0); ui.add_space(4.0);
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.max_height(420.0) .max_height(420.0_f32.min(screen.y - 160.0).max(120.0))
.show(ui, |ui| { .show(ui, |ui| {
if items.is_empty() { if items.is_empty() {
ui.label(RichText::new("Nothing matches.").color(p.muted)); ui.label(RichText::new("Nothing matches.").color(p.muted));
+10 -1
View File
@@ -67,7 +67,16 @@ pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
"label": r.label, "label": r.label,
"rule": r.rule_above, "rule": r.rule_above,
"currency": r.currency, "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); let p = amount_parts(a);
serde_json::json!({ "digits": p.digits, "negative": p.negative }) serde_json::json!({ "digits": p.digits, "negative": p.negative })
})).collect::<Vec<_>>(), })).collect::<Vec<_>>(),
+5 -1
View File
@@ -102,6 +102,9 @@ pub struct Statement {
pub columns: Vec<String>, pub columns: Vec<String>,
/// The rows, top to bottom. /// The rows, top to bottom.
pub rows: Vec<Row>, 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 { 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. /// 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; let mut above: Option<RowKind> = None;
for r in &mut rows { for r in &mut rows {
r.currency = matches!(r.kind, RowKind::Subtotal | RowKind::Total); 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, period,
columns: columns.iter().map(|c| c.to_string()).collect(), columns: columns.iter().map(|c| c.to_string()).collect(),
rows, rows,
percent_from: None,
} }
} }
+9 -4
View File
@@ -18,15 +18,20 @@
#let d = json(bytes(sys.inputs.data)) #let d = json(bytes(sys.inputs.data))
#let ncols = d.columns.len() #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 ink = luma(25)
#let quiet = luma(110) #let quiet = luma(110)
#let hair = 0.5pt + luma(160) #let hair = 0.5pt + luma(160)
#let firm = 0.6pt + ink #let firm = 0.6pt + ink
#set document(title: d.company + " — " + d.title, author: d.company) #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( #set page(
paper: "us-letter", paper: "us-letter",
flipped: wide,
margin: (x: 0.75in, top: 0.75in, bottom: 0.75in), margin: (x: 0.75in, top: 0.75in, bottom: 0.75in),
header: context { header: context {
if counter(page).get().first() > 1 { if counter(page).get().first() > 1 {
@@ -58,7 +63,7 @@
if a == none { return [] } if a == none { return [] }
let open = if a.negative { [(] } else { [] } let open = if a.negative { [(] } else { [] }
let close = if a.negative { [)] } else { hide[)] } 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] [#open#sign#a.digits#close]
} }
@@ -76,7 +81,7 @@
} }
#table( #table(
columns: (1fr,) + (8em,) * ncols, columns: (1fr,) + (if dense { 6.6em } else { 8em },) * ncols,
stroke: none, stroke: none,
inset: (x: 4pt, y: 3.6pt), inset: (x: 4pt, y: 3.6pt),
align: (left + horizon,) + (right + horizon,) * ncols, align: (left + horizon,) + (right + horizon,) * ncols,
@@ -84,7 +89,7 @@
table.cell(stroke: (top: firm, bottom: hair), []), table.cell(stroke: (top: firm, bottom: hair), []),
..d.columns.map(c => table.cell( ..d.columns.map(c => table.cell(
stroke: (top: firm, bottom: hair), 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 ..d