//! PDF export via the bundled `typst` subprocess. //! //! The template ships in `templates/sheet.typ` and is embedded at compile //! time. At export we write both the template and a data.json to a temp //! directory, invoke `typst compile`, and hand back the output path. use std::fs; use std::path::PathBuf; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; use chrono::NaiveDate; use ripgrow_core::{ AnyPrescription, DistancePrescription, Exercise, Prescription, ResistanceType, TimedPrescription, }; const TEMPLATE: &str = include_str!("../templates/sheet.typ"); #[derive(Debug, Clone, PartialEq)] pub struct SessionExport { pub profile: String, pub date: NaiveDate, pub warmup: Vec, pub slots: Vec, } #[derive(Debug, Clone, PartialEq)] pub struct SlotExport { pub name: String, /// Set count as a string. `"-"` when the exercise is cardio and set /// count doesn't apply. pub sets: String, /// Target reps per set. `"-"` for cardio. pub reps: String, /// Load number as a string. `"BW"` for bodyweight-only exercises, /// `"BW+10"` for bodyweight with added load, `"82.5"` for freeweight /// or cardio. pub load: String, /// Unit for the load, if any. Empty string for bodyweight-only. pub unit: String, /// `"up"` / `"flat"` / `"down"` or empty when there's no prior session. pub glyph: String, } /// Decomposed prescription, ready for a tabular layout. #[derive(Debug, Clone, PartialEq)] pub struct PrescriptionParts { pub sets: String, pub reps: String, pub load: String, pub unit: String, } impl PrescriptionParts { /// Reps-shaped prescription. Bodyweight renders as `BW` (or `BW+N` /// for weighted bodyweight); everything else prints the numeric /// load with the exercise's load unit. pub fn from(ex: &Exercise, p: &Prescription) -> Self { match ex.resistance_type { ResistanceType::Bodyweight if p.load.get() == 0.0 => Self { sets: p.sets.to_string(), reps: p.reps.to_string(), load: "BW".to_string(), unit: String::new(), }, ResistanceType::Bodyweight => Self { sets: p.sets.to_string(), reps: p.reps.to_string(), load: format!("BW+{}", trim(p.load.get())), unit: ex.load_unit.as_str().to_string(), }, ResistanceType::CardioTime | ResistanceType::CardioDistance => { // Cardio exercises route through TimedKind / DistanceKind // now, so this branch is unreachable for real callers. // Kept defensive rather than panicking on an exotic mix // (e.g. a manually-crafted Prescription for a cardio ex). Self::no_history() } ResistanceType::Freeweight | ResistanceType::Machine => Self { sets: p.sets.to_string(), reps: p.reps.to_string(), load: trim(p.load.get()), unit: ex.load_unit.as_str().to_string(), }, } } /// Timed prescription — plank holds and the like. Reps slot reads /// `-` (a timed set has no rep count); the duration lands in the /// load column with its own display (`1m30s`) and the unit column /// stays empty. pub fn from_timed(_ex: &Exercise, p: &TimedPrescription) -> Self { Self { sets: p.sets.to_string(), reps: "-".to_string(), load: p.duration.to_string(), unit: String::new(), } } /// Distance prescription. Distance is track-only today /// (`DistanceKind::compute_prescription` always returns `NoHistory`), /// so this factory exists purely for exhaustiveness at the dispatch /// boundary — it renders the same as [`Self::no_history`]. pub fn from_distance(_ex: &Exercise, _p: &DistancePrescription) -> Self { Self::no_history() } /// Dispatch on kind. The reps-shaped branch is the only one that /// currently distinguishes bodyweight from freeweight; timed and /// distance render kind-uniformly. pub fn from_any(ex: &Exercise, p: &AnyPrescription) -> Self { match p { AnyPrescription::Reps(p) => Self::from(ex, p), AnyPrescription::Timed(p) => Self::from_timed(ex, p), AnyPrescription::Distance(p) => Self::from_distance(ex, p), } } /// "Seed on first log" placeholder for any kind's `NoHistory` case. /// The Generate screen uses this so the PDF and the on-screen /// preview render the same slot when there's nothing to prescribe. pub fn no_history() -> Self { Self { sets: "-".to_string(), reps: "-".to_string(), load: "seed".to_string(), unit: String::new(), } } } #[derive(Debug, thiserror::Error)] pub enum ExportError { #[error("typst not on PATH — install it (e.g. `brew install typst`)")] TypstMissing, #[error("typst exited with status {status}: {stderr}")] TypstFailed { status: i32, stderr: String }, #[error("io: {0}")] Io(#[from] std::io::Error), } /// Render `session` to a PDF and return the output path. `output_dir` must /// exist and be writable; the file lands there as /// `ripgrow--.pdf`. pub fn export_to_pdf( session: &SessionExport, output_dir: &std::path::Path, ) -> Result { fs::create_dir_all(output_dir)?; let scratch = scratch_dir()?; let data_path = scratch.join("data.json"); let template_path = scratch.join("sheet.typ"); let output_path = output_dir.join(format!( "ripgrow-{}-{}.pdf", slugify(&session.profile), session.date.format("%Y-%m-%d") )); fs::write(&data_path, render_json(session))?; fs::write(&template_path, TEMPLATE)?; // Typst confines file reads to the template's root and treats any path // passed via --input as relative to it, so hand the template a bare // filename; both live in the same scratch dir. let status = Command::new("typst") .arg("compile") .arg("--input") .arg("data-path=data.json") .arg(&template_path) .arg(&output_path) .output(); let output = match status { Ok(o) => o, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { return Err(ExportError::TypstMissing); } Err(e) => return Err(ExportError::Io(e)), }; if !output.status.success() { return Err(ExportError::TypstFailed { status: output.status.code().unwrap_or(-1), stderr: String::from_utf8_lossy(&output.stderr).into_owned(), }); } // Best-effort scratch cleanup; ignore errors. let _ = fs::remove_dir_all(&scratch); Ok(output_path) } /// Format the numeric prescription as a single line for the on-screen /// preview. The PDF template consumes the decomposed [`PrescriptionParts`] /// instead so the tabular layout can align columns. pub fn format_prescription(ex: &Exercise, p: &Prescription) -> String { let parts = PrescriptionParts::from(ex, p); let (sets, reps, load, unit) = (parts.sets, parts.reps, parts.load, parts.unit); match ex.resistance_type { ResistanceType::Bodyweight if p.load.get() == 0.0 => format!("{sets} x {reps}"), ResistanceType::Bodyweight => format!("{sets} x {reps} {load} {unit}"), ResistanceType::CardioTime | ResistanceType::CardioDistance => { format!("{load} {unit}") } ResistanceType::Freeweight | ResistanceType::Machine => { format!("{sets} x {reps} @ {load} {unit}") } } } /// Drop trailing zeros from a float when it prints cleanly as an integer /// (`80.0` -> `80`). Otherwise defer to the default `{}` formatting. fn trim(load: f64) -> String { if (load - load.round()).abs() < 1e-9 { format!("{}", load as i64) } else { format!("{load}") } } fn render_json(session: &SessionExport) -> String { let mut out = String::from("{"); out.push_str(&format!("\"profile\":{},", escape(&session.profile))); out.push_str(&format!( "\"date\":{},", escape(&session.date.format("%Y-%m-%d").to_string()) )); out.push_str("\"warmup\":["); for (i, w) in session.warmup.iter().enumerate() { if i > 0 { out.push(','); } out.push_str(&escape(w)); } out.push_str("],\"slots\":["); for (i, s) in session.slots.iter().enumerate() { if i > 0 { out.push(','); } out.push('{'); out.push_str(&format!("\"name\":{},", escape(&s.name))); out.push_str(&format!("\"sets\":{},", escape(&s.sets))); out.push_str(&format!("\"reps\":{},", escape(&s.reps))); out.push_str(&format!("\"load\":{},", escape(&s.load))); out.push_str(&format!("\"unit\":{},", escape(&s.unit))); out.push_str(&format!("\"glyph\":{}", escape(&s.glyph))); out.push('}'); } out.push_str("]}"); out } fn escape(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), c if (c as u32) < 0x20 => { out.push_str(&format!("\\u{:04x}", c as u32)); } c => out.push(c), } } out.push('"'); out } fn slugify(name: &str) -> String { // Same rules as ripgrow_core::slugify — duplicated here to keep this // module dependency-light (no core cycle needed for one export path). let mut out = String::with_capacity(name.len()); let mut prev_hyphen = true; for c in name.chars() { let ch = c.to_ascii_lowercase(); if ch.is_ascii_alphanumeric() { out.push(ch); prev_hyphen = false; } else if !prev_hyphen { out.push('-'); prev_hyphen = true; } } while out.ends_with('-') { out.pop(); } if out.is_empty() { "profile".to_string() } else { out } } fn scratch_dir() -> Result { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); let dir = std::env::temp_dir().join(format!("ripgrow-pdf-{}-{}", std::process::id(), nanos)); fs::create_dir_all(&dir)?; Ok(dir) } #[cfg(test)] mod tests { use super::*; use ripgrow_core::{EffortKind, LoadUnit, Prescription, ResistanceType, State}; fn ex(name: &str, rt: ResistanceType, unit: &str) -> Exercise { Exercise { id: 0, name: name.to_string(), resistance_type: rt, effort_kind: EffortKind::Reps, load_unit: LoadUnit::parse(unit).unwrap(), increment: 2.5, notes: String::new(), tag_ids: vec![], } } fn p(sets: i32, reps: i32, load: f64) -> Prescription { Prescription { state: State::Progressing, sets, reps: ripgrow_core::Reps::new(reps).unwrap(), load: ripgrow_core::Load::new(load).unwrap(), } } #[test] fn parts_freeweight() { let parts = PrescriptionParts::from( &ex("bench", ResistanceType::Freeweight, "kg"), &p(3, 5, 82.5), ); assert_eq!(parts.sets, "3"); assert_eq!(parts.reps, "5"); assert_eq!(parts.load, "82.5"); assert_eq!(parts.unit, "kg"); } #[test] fn parts_trims_integer_loads() { let parts = PrescriptionParts::from( &ex("bench", ResistanceType::Freeweight, "kg"), &p(3, 5, 80.0), ); assert_eq!(parts.load, "80"); } #[test] fn parts_bodyweight_zero_load() { let parts = PrescriptionParts::from( &ex("pullup", ResistanceType::Bodyweight, "kg"), &p(3, 8, 0.0), ); assert_eq!(parts.load, "BW"); assert_eq!(parts.unit, ""); } #[test] fn parts_bodyweight_with_added_load() { let parts = PrescriptionParts::from( &ex("pullup", ResistanceType::Bodyweight, "kg"), &p(3, 5, 10.0), ); assert_eq!(parts.load, "BW+10"); assert_eq!(parts.unit, "kg"); } #[test] fn parts_timed_uses_duration_in_load_column() { use ripgrow_core::{Duration, TimedPrescription}; let pres = TimedPrescription { state: State::Progressing, sets: 3, duration: Duration::from_seconds(90).unwrap(), }; let parts = PrescriptionParts::from_timed( &ex("plank", ResistanceType::CardioTime, "kg"), &pres, ); assert_eq!(parts.sets, "3"); assert_eq!(parts.reps, "-"); assert_eq!(parts.load, "1m30s"); assert_eq!(parts.unit, ""); } #[test] fn no_history_renders_seed_placeholder() { let parts = PrescriptionParts::no_history(); assert_eq!(parts.sets, "-"); assert_eq!(parts.reps, "-"); assert_eq!(parts.load, "seed"); assert_eq!(parts.unit, ""); } #[test] fn from_any_dispatches_by_kind() { use ripgrow_core::{AnyPrescription, Duration, TimedPrescription}; let reps_parts = PrescriptionParts::from_any( &ex("bench", ResistanceType::Freeweight, "kg"), &AnyPrescription::Reps(p(3, 5, 82.5)), ); assert_eq!(reps_parts.load, "82.5"); let timed_parts = PrescriptionParts::from_any( &ex("plank", ResistanceType::CardioTime, "kg"), &AnyPrescription::Timed(TimedPrescription { state: State::Progressing, sets: 3, duration: Duration::from_seconds(60).unwrap(), }), ); assert_eq!(timed_parts.load, "1m00s"); assert_eq!(timed_parts.reps, "-"); } #[test] fn format_prescription_still_composes_a_one_liner() { let s = format_prescription(&ex("bench", ResistanceType::Freeweight, "kg"), &p(3, 5, 82.5)); assert_eq!(s, "3 x 5 @ 82.5 kg"); } #[test] fn render_json_shape_is_stable() { let s = SessionExport { profile: "self".into(), date: NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(), warmup: vec!["chest".into(), "shoulders".into()], slots: vec![SlotExport { name: "bench".into(), sets: "3".into(), reps: "5".into(), load: "82.5".into(), unit: "kg".into(), glyph: "up".into(), }], }; let expected = r#"{"profile":"self","date":"2026-07-18","warmup":["chest","shoulders"],"slots":[{"name":"bench","sets":"3","reps":"5","load":"82.5","unit":"kg","glyph":"up"}]}"#; assert_eq!(render_json(&s), expected); } #[test] fn render_json_escapes_specials() { let s = SessionExport { profile: "alice \"the\" trainer".into(), date: NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(), warmup: vec![], slots: vec![SlotExport { name: "back\\slash".into(), sets: "1".into(), reps: "1".into(), load: "0".into(), unit: "".into(), glyph: "".into(), }], }; let out = render_json(&s); assert!(out.contains(r#"\"the\""#)); assert!(out.contains(r#"back\\slash"#)); } fn typst_available() -> bool { std::process::Command::new("typst") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false) } #[test] fn export_produces_pdf_end_to_end() { if !typst_available() { eprintln!("skipping: typst not on PATH"); return; } // Mixed-kind session: reps (freeweight + bodyweight), a timed // slot with duration in the load column, and a distance slot in // the seed-on-first-log state. Exercises the full PrescriptionParts // shape space through the template. let s = SessionExport { profile: "self".into(), date: NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(), warmup: vec!["chest".into(), "shoulders".into(), "core".into()], slots: vec![ SlotExport { name: "bench".into(), sets: "3".into(), reps: "5".into(), load: "82.5".into(), unit: "kg".into(), glyph: "up".into(), }, SlotExport { name: "pullup".into(), sets: "3".into(), reps: "8".into(), load: "BW".into(), unit: "".into(), glyph: "flat".into(), }, SlotExport { name: "plank".into(), sets: "3".into(), reps: "-".into(), load: "1m30s".into(), unit: "".into(), glyph: "up".into(), }, SlotExport { name: "5k run".into(), sets: "-".into(), reps: "-".into(), load: "seed".into(), unit: "".into(), glyph: "".into(), }, SlotExport { name: "row \"paused\"".into(), sets: "3".into(), reps: "6".into(), load: "60".into(), unit: "kg".into(), glyph: "".into(), }, ], }; let dir = std::env::temp_dir().join(format!( "ripgrow-pdf-e2e-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(&dir).unwrap(); let out = export_to_pdf(&s, &dir).unwrap(); assert!(out.exists(), "output PDF should exist at {}", out.display()); let bytes = std::fs::read(&out).unwrap(); assert!(bytes.starts_with(b"%PDF-"), "output should be a valid PDF"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn export_returns_typst_missing_when_absent() { // Guards against a regression in the missing-binary error path. // Skips when typst is present; the end-to-end test covers the // happy path. if typst_available() { eprintln!("skipping: typst is on PATH in this environment"); return; } let s = SessionExport { profile: "test".into(), date: NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(), warmup: vec![], slots: vec![], }; let dir = std::env::temp_dir(); let err = export_to_pdf(&s, &dir).unwrap_err(); assert!(matches!(err, ExportError::TypstMissing)); } }