diff --git a/crates/ledger-gui/Cargo.toml b/crates/ledger-gui/Cargo.toml index 441da96..2f25697 100644 --- a/crates/ledger-gui/Cargo.toml +++ b/crates/ledger-gui/Cargo.toml @@ -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"] } diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 779325e..93ba568 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -281,61 +281,74 @@ 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); - for s in Section::ALL { - let on = s == current; - 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); - ui.painter().rect_stroke( - rect, - CornerRadius::same(8), - Stroke::new(1.0, p.border), - egui::StrokeKind::Inside, - ); - } else if resp.hovered() { - ui.painter() - .rect_filled(rect, CornerRadius::same(8), p.hover); - } - let color = if on { p.text } else { p.muted }; - ui.painter().text( - rect.left_center() + vec2(10.0, 0.0), - egui::Align2::LEFT_CENTER, - s.icon(), - egui::FontId::proportional(16.0), - if on { p.accent } else { p.muted }, - ); - ui.painter().text( - rect.left_center() + vec2(36.0, 0.0), - egui::Align2::LEFT_CENTER, - s.title(), - if on { - theme::semibold(13.5) - } else { - theme::regular(13.5) - }, - color, - ); - if s == Section::Overview && attention > 0 { - let badge = egui::Rect::from_center_size( - 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().text( - badge.center(), - egui::Align2::CENTER_CENTER, - attention.to_string(), - theme::semibold(11.0), - p.warning, - ); - } - if resp.clicked() { - self.nav.go(s.home()); - } - } + // 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(), + ); + if on { + ui.painter() + .rect_filled(rect, CornerRadius::same(8), p.surface); + ui.painter().rect_stroke( + rect, + CornerRadius::same(8), + Stroke::new(1.0, p.border), + egui::StrokeKind::Inside, + ); + } else if resp.hovered() { + ui.painter() + .rect_filled(rect, CornerRadius::same(8), p.hover); + } + let color = if on { p.text } else { p.muted }; + ui.painter().text( + rect.left_center() + vec2(10.0, 0.0), + egui::Align2::LEFT_CENTER, + s.icon(), + egui::FontId::proportional(16.0), + if on { p.accent } else { p.muted }, + ); + ui.painter().text( + rect.left_center() + vec2(36.0, 0.0), + egui::Align2::LEFT_CENTER, + s.title(), + if on { + theme::semibold(13.5) + } else { + theme::regular(13.5) + }, + color, + ); + if s == Section::Overview && attention > 0 { + let badge = egui::Rect::from_center_size( + 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().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| { let label = if self.dark { format!("{} Light mode", icon::SUN) @@ -403,41 +416,52 @@ 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); - for s in Section::ALL { - let on = s == current; - 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); - ui.painter().rect_stroke( - rect, - CornerRadius::same(8), - Stroke::new(1.0, p.border), - egui::StrokeKind::Inside, - ); - } else if resp.hovered() { - ui.painter() - .rect_filled(rect, CornerRadius::same(8), p.hover); - } - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - s.icon(), - egui::FontId::proportional(18.0), - 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); - format!("{} · {attention} need attention", s.title()) - } else { - s.title().to_string() - }; - if resp.on_hover_text(tip).clicked() { - self.nav.go(s.home()); - } - } + 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(), + ); + if on { + ui.painter() + .rect_filled(rect, CornerRadius::same(8), p.surface); + ui.painter().rect_stroke( + rect, + CornerRadius::same(8), + Stroke::new(1.0, p.border), + egui::StrokeKind::Inside, + ); + } else if resp.hovered() { + ui.painter() + .rect_filled(rect, CornerRadius::same(8), p.hover); + } + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + s.icon(), + egui::FontId::proportional(18.0), + 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, + ); + 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| { 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()); diff --git a/crates/ledger-gui/src/export.rs b/crates/ledger-gui/src/export.rs new file mode 100644 index 0000000..82eb790 --- /dev/null +++ b/crates/ledger-gui/src/export.rs @@ -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 { + 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" + ); + } +} diff --git a/crates/ledger-gui/src/lib.rs b/crates/ledger-gui/src/lib.rs index 8f4ca8f..4bcd33b 100644 --- a/crates/ledger-gui/src/lib.rs +++ b/crates/ledger-gui/src/lib.rs @@ -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; diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index a32184d..d1eb35b 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -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>>, + /// How the last PDF went, and when (to fade the message). + pdf_done: Option<(Result, 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 = 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); + } } diff --git a/crates/ledger-gui/src/palette.rs b/crates/ledger-gui/src/palette.rs index 9a7def7..0d150f9 100644 --- a/crates/ledger-gui/src/palette.rs +++ b/crates/ledger-gui/src/palette.rs @@ -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)); diff --git a/crates/ledger-import/src/pdf.rs b/crates/ledger-import/src/pdf.rs index a4ff5b0..e653617 100644 --- a/crates/ledger-import/src/pdf.rs +++ b/crates/ledger-import/src/pdf.rs @@ -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::>(), diff --git a/crates/ledger-import/src/report.rs b/crates/ledger-import/src/report.rs index 61ddb00..c6baa1f 100644 --- a/crates/ledger-import/src/report.rs +++ b/crates/ledger-import/src/report.rs @@ -102,6 +102,9 @@ pub struct Statement { pub columns: Vec, /// The rows, top to bottom. pub rows: Vec, + /// 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, } fn row(kind: RowKind, label: impl Into, amounts: Vec>) -> 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) -> Statement { +pub fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec) -> Statement { let mut above: Option = 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) period, columns: columns.iter().map(|c| c.to_string()).collect(), rows, + percent_from: None, } } diff --git a/crates/ledger-import/templates/statement.typ b/crates/ledger-import/templates/statement.typ index 45c3773..76d22aa 100644 --- a/crates/ledger-import/templates/statement.typ +++ b/crates/ledger-import/templates/statement.typ @@ -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