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:
1 parent
ea996f6e7e
commit
12308fe3e9
9 files changed
+362
-19
No files matched your search
@@ -14,6 +14,7 @@ path = "src/main.rs"
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
ledger-store = { path = "../ledger-store" }
|
||||
ledger-config = { path = "../ledger-config" }
|
||||
ledger-import = { path = "../ledger-import" }
|
||||
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11", "persistence"] }
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] }
|
||||
|
||||
@@ -281,10 +281,19 @@ impl App {
|
||||
ui.add_space(18.0);
|
||||
let current = self.nav.current.section();
|
||||
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
|
||||
// Short windows: the links scroll above the status box
|
||||
// instead of the box drawing over them.
|
||||
let links_h = (ui.available_height() - SIDEBAR_FOOTER).max(64.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("sidebar_links")
|
||||
.max_height(links_h)
|
||||
.show(ui, |ui| {
|
||||
for s in Section::ALL {
|
||||
let on = s == current;
|
||||
let (rect, resp) =
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
|
||||
let (rect, resp) = ui.allocate_exact_size(
|
||||
vec2(ui.available_width(), 32.0),
|
||||
Sense::click(),
|
||||
);
|
||||
if on {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(8), p.surface);
|
||||
@@ -322,8 +331,11 @@ impl App {
|
||||
rect.right_center() - vec2(18.0, 0.0),
|
||||
vec2(22.0, 18.0),
|
||||
);
|
||||
ui.painter()
|
||||
.rect_filled(badge, CornerRadius::same(9), p.warning_soft);
|
||||
ui.painter().rect_filled(
|
||||
badge,
|
||||
CornerRadius::same(9),
|
||||
p.warning_soft,
|
||||
);
|
||||
ui.painter().text(
|
||||
badge.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
@@ -336,6 +348,7 @@ impl App {
|
||||
self.nav.go(s.home());
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
||||
let label = if self.dark {
|
||||
format!("{} Light mode", icon::SUN)
|
||||
@@ -403,10 +416,17 @@ impl App {
|
||||
ui.add_space(18.0);
|
||||
let current = self.nav.current.section();
|
||||
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
|
||||
let links_h = (ui.available_height() - RAIL_FOOTER).max(72.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("rail_links")
|
||||
.max_height(links_h)
|
||||
.show(ui, |ui| {
|
||||
for s in Section::ALL {
|
||||
let on = s == current;
|
||||
let (rect, resp) =
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click());
|
||||
let (rect, resp) = ui.allocate_exact_size(
|
||||
vec2(ui.available_width(), 36.0),
|
||||
Sense::click(),
|
||||
);
|
||||
if on {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(8), p.surface);
|
||||
@@ -428,8 +448,11 @@ impl App {
|
||||
if on { p.accent } else { p.muted },
|
||||
);
|
||||
let tip = if s == Section::Overview && attention > 0 {
|
||||
ui.painter()
|
||||
.circle_filled(rect.center() + vec2(9.0, -8.0), 4.0, p.warning);
|
||||
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()
|
||||
@@ -438,6 +461,7 @@ impl App {
|
||||
self.nav.go(s.home());
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
|
||||
let (glyph, tip) = if self.dark {
|
||||
(icon::SUN, "Light mode")
|
||||
@@ -616,6 +640,10 @@ impl App {
|
||||
|
||||
/// Width of the icon rail.
|
||||
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) {
|
||||
let p = pal(ui.ctx());
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod books;
|
||||
mod charts;
|
||||
pub mod data;
|
||||
pub mod demo;
|
||||
mod export;
|
||||
mod fmt;
|
||||
pub mod nav;
|
||||
pub mod pages;
|
||||
|
||||
@@ -103,6 +103,32 @@ pub struct State {
|
||||
pub range: Range,
|
||||
pub columns: Columns,
|
||||
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 {
|
||||
let mut out = String::from("\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))];
|
||||
if let Some((ps, pe)) = prior(st, s, e) {
|
||||
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
|
||||
}
|
||||
@@ -1087,6 +1161,7 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>, picke
|
||||
} else {
|
||||
Compare::None
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let cols = column_ranges(&state, b);
|
||||
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 {
|
||||
render(ui, &t, shell);
|
||||
render(ui, &t, &PdfInfo::new(b, kind.title(), &subtitle), shell);
|
||||
}
|
||||
if let Some(k) = pick {
|
||||
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 tsv = t.tsv();
|
||||
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| {
|
||||
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
|
||||
.add(
|
||||
egui::Button::new(
|
||||
@@ -1218,6 +1329,31 @@ fn render(ui: &mut Ui, t: &Table, shell: &mut Shell<'_>) {
|
||||
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 base: f32 = if t.columns.len() > 6 { 112.0 } else { 136.0 };
|
||||
// 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 {
|
||||
shell.state.accounts.range = Some((d.start, d.end));
|
||||
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() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,9 @@ impl Palette {
|
||||
.inner_margin(Margin::same(10)),
|
||||
)
|
||||
.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)
|
||||
.hint_text("Search entries, accounts, vendors, documents, reports…")
|
||||
.font(theme::regular(16.0))
|
||||
@@ -89,7 +91,7 @@ impl Palette {
|
||||
crate::widgets::rule(ui);
|
||||
ui.add_space(4.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(420.0)
|
||||
.max_height(420.0_f32.min(screen.y - 160.0).max(120.0))
|
||||
.show(ui, |ui| {
|
||||
if items.is_empty() {
|
||||
ui.label(RichText::new("Nothing matches.").color(p.muted));
|
||||
|
||||
@@ -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<_>>(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user