Skip to main content

max / ripgrow

pdf export via typst subprocess Template at crates/ripgrow-tui/templates/sheet.typ, embedded via include_str! at build time. Contents match the design's minimum: profile + date header, warmup line, numbered slots with name, prescription, directional glyph. No notes area, no checkboxes, no history graphs, no motivational copy. pdf::export_to_pdf writes the template and a hand-serialized data.json to a temp scratch dir, runs `typst compile --input data-path=...`, and returns the output path. Missing-typst error is surfaced clearly (TypstMissing → 'install it, e.g. brew install typst') rather than a raw io::Error. Hand-rolled JSON escaper keeps the crate free of a serde/serde_json dep for this one small path. format_prescription branches on resistance type per the design: `3 x 5 @ 82.5 kg` (freeweight/machine), `3 x 8` (bodyweight, zero load), `3 x 5 + 10 kg` (bodyweight with added load), `12 min` (cardio). Trailing zeros on integer loads are trimmed (`80.0` → `80`). Generate screen's `e` now composes a SessionExport from the current preview state (respecting rerolls and drops) and writes to ~/Downloads/ripgrow-<profile-slug>-<date>.pdf. Status line reports the output path on success or a clear error on failure. 83 tests total (49 core + 34 tui), all passing. `typst` is not required to build or test; it's checked at export time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 16:59 UTC
Commit: d1c2a57874c1d9802d6169ed74ffe60772490986
Parent: 2dcd87a
4 files changed, +433 insertions, -3 deletions
@@ -3,6 +3,7 @@ use std::time::Duration;
3 3 use crossterm::event::{self, Event};
4 4
5 5 mod app;
6 + mod pdf;
6 7 mod screens;
7 8 mod tui;
8 9
@@ -0,0 +1,319 @@
1 + //! PDF export via the bundled `typst` subprocess.
2 + //!
3 + //! The template ships in `templates/sheet.typ` and is embedded at compile
4 + //! time. At export we write both the template and a data.json to a temp
5 + //! directory, invoke `typst compile`, and hand back the output path.
6 +
7 + use std::fs;
8 + use std::path::PathBuf;
9 + use std::process::Command;
10 + use std::time::{SystemTime, UNIX_EPOCH};
11 +
12 + use chrono::NaiveDate;
13 + use ripgrow_core::{Exercise, Prescription, ResistanceType};
14 +
15 + const TEMPLATE: &str = include_str!("../templates/sheet.typ");
16 +
17 + #[derive(Debug, Clone, PartialEq)]
18 + pub struct SessionExport {
19 + pub profile: String,
20 + pub date: NaiveDate,
21 + pub warmup: Vec<String>,
22 + pub slots: Vec<SlotExport>,
23 + }
24 +
25 + #[derive(Debug, Clone, PartialEq)]
26 + pub struct SlotExport {
27 + pub name: String,
28 + pub prescription: String,
29 + pub glyph: String,
30 + }
31 +
32 + #[derive(Debug, thiserror::Error)]
33 + pub enum ExportError {
34 + #[error("typst not on PATH — install it (e.g. `brew install typst`)")]
35 + TypstMissing,
36 + #[error("typst exited with status {status}: {stderr}")]
37 + TypstFailed { status: i32, stderr: String },
38 + #[error("io: {0}")]
39 + Io(#[from] std::io::Error),
40 + }
41 +
42 + /// Render `session` to a PDF and return the output path. `output_dir` must
43 + /// exist and be writable; the file lands there as
44 + /// `ripgrow-<profile-slug>-<date>.pdf`.
45 + pub fn export_to_pdf(
46 + session: &SessionExport,
47 + output_dir: &std::path::Path,
48 + ) -> Result<PathBuf, ExportError> {
49 + fs::create_dir_all(output_dir)?;
50 + let scratch = scratch_dir()?;
51 + let data_path = scratch.join("data.json");
52 + let template_path = scratch.join("sheet.typ");
53 + let output_path = output_dir.join(format!(
54 + "ripgrow-{}-{}.pdf",
55 + slugify(&session.profile),
56 + session.date.format("%Y-%m-%d")
57 + ));
58 +
59 + fs::write(&data_path, render_json(session))?;
60 + fs::write(&template_path, TEMPLATE)?;
61 +
62 + let status = Command::new("typst")
63 + .arg("compile")
64 + .arg("--input")
65 + .arg(format!("data-path={}", data_path.display()))
66 + .arg(&template_path)
67 + .arg(&output_path)
68 + .output();
69 +
70 + let output = match status {
71 + Ok(o) => o,
72 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
73 + return Err(ExportError::TypstMissing);
74 + }
75 + Err(e) => return Err(ExportError::Io(e)),
76 + };
77 +
78 + if !output.status.success() {
79 + return Err(ExportError::TypstFailed {
80 + status: output.status.code().unwrap_or(-1),
81 + stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
82 + });
83 + }
84 +
85 + // Best-effort scratch cleanup; ignore errors.
86 + let _ = fs::remove_dir_all(&scratch);
87 +
88 + Ok(output_path)
89 + }
90 +
91 + /// Format the numeric prescription per the design note: freeweight/machine
92 + /// as `S x R @ L unit`; bodyweight collapses when load is zero; cardio
93 + /// renders load with its unit (min/km).
94 + pub fn format_prescription(ex: &Exercise, p: &Prescription) -> String {
95 + match ex.resistance_type {
96 + ResistanceType::Bodyweight if p.load == 0.0 => {
97 + format!("{} x {}", p.sets, p.reps)
98 + }
99 + ResistanceType::Bodyweight => {
100 + format!("{} x {} + {} {}", p.sets, p.reps, trim(p.load), ex.load_unit)
101 + }
102 + ResistanceType::CardioTime | ResistanceType::CardioDistance => {
103 + format!("{} {}", trim(p.load), ex.load_unit)
104 + }
105 + ResistanceType::Freeweight | ResistanceType::Machine => {
106 + format!("{} x {} @ {} {}", p.sets, p.reps, trim(p.load), ex.load_unit)
107 + }
108 + }
109 + }
110 +
111 + /// Drop trailing zeros from a float when it prints cleanly as an integer
112 + /// (`80.0` -> `80`). Otherwise defer to the default `{}` formatting.
113 + fn trim(load: f64) -> String {
114 + if (load - load.round()).abs() < 1e-9 {
115 + format!("{}", load as i64)
116 + } else {
117 + format!("{load}")
118 + }
119 + }
120 +
121 + fn render_json(session: &SessionExport) -> String {
122 + let mut out = String::from("{");
123 + out.push_str(&format!("\"profile\":{},", escape(&session.profile)));
124 + out.push_str(&format!(
125 + "\"date\":{},",
126 + escape(&session.date.format("%Y-%m-%d").to_string())
127 + ));
128 + out.push_str("\"warmup\":[");
129 + for (i, w) in session.warmup.iter().enumerate() {
130 + if i > 0 {
131 + out.push(',');
132 + }
133 + out.push_str(&escape(w));
134 + }
135 + out.push_str("],\"slots\":[");
136 + for (i, s) in session.slots.iter().enumerate() {
137 + if i > 0 {
138 + out.push(',');
139 + }
140 + out.push('{');
141 + out.push_str(&format!("\"name\":{},", escape(&s.name)));
142 + out.push_str(&format!("\"prescription\":{},", escape(&s.prescription)));
143 + out.push_str(&format!("\"glyph\":{}", escape(&s.glyph)));
144 + out.push('}');
145 + }
146 + out.push_str("]}");
147 + out
148 + }
149 +
150 + fn escape(s: &str) -> String {
151 + let mut out = String::with_capacity(s.len() + 2);
152 + out.push('"');
153 + for c in s.chars() {
154 + match c {
155 + '"' => out.push_str("\\\""),
156 + '\\' => out.push_str("\\\\"),
157 + '\n' => out.push_str("\\n"),
158 + '\r' => out.push_str("\\r"),
159 + '\t' => out.push_str("\\t"),
160 + c if (c as u32) < 0x20 => {
161 + out.push_str(&format!("\\u{:04x}", c as u32));
162 + }
163 + c => out.push(c),
164 + }
165 + }
166 + out.push('"');
167 + out
168 + }
169 +
170 + fn slugify(name: &str) -> String {
171 + // Same rules as ripgrow_core::slugify — duplicated here to keep this
172 + // module dependency-light (no core cycle needed for one export path).
173 + let mut out = String::with_capacity(name.len());
174 + let mut prev_hyphen = true;
175 + for c in name.chars() {
176 + let ch = c.to_ascii_lowercase();
177 + if ch.is_ascii_alphanumeric() {
178 + out.push(ch);
179 + prev_hyphen = false;
180 + } else if !prev_hyphen {
181 + out.push('-');
182 + prev_hyphen = true;
183 + }
184 + }
185 + while out.ends_with('-') {
186 + out.pop();
187 + }
188 + if out.is_empty() {
189 + "profile".to_string()
190 + } else {
191 + out
192 + }
193 + }
194 +
195 + fn scratch_dir() -> Result<PathBuf, std::io::Error> {
196 + let nanos = SystemTime::now()
197 + .duration_since(UNIX_EPOCH)
198 + .map(|d| d.as_nanos())
199 + .unwrap_or(0);
200 + let dir = std::env::temp_dir().join(format!("ripgrow-pdf-{}-{}", std::process::id(), nanos));
201 + fs::create_dir_all(&dir)?;
202 + Ok(dir)
203 + }
204 +
205 + #[cfg(test)]
206 + mod tests {
207 + use super::*;
208 + use ripgrow_core::{Prescription, ResistanceType, State};
209 +
210 + fn ex(name: &str, rt: ResistanceType, unit: &str) -> Exercise {
211 + Exercise {
212 + id: 0,
213 + name: name.to_string(),
214 + resistance_type: rt,
215 + load_unit: unit.to_string(),
216 + increment: 2.5,
217 + notes: String::new(),
218 + tag_ids: vec![],
219 + }
220 + }
221 +
222 + fn p(sets: i32, reps: i32, load: f64) -> Prescription {
223 + Prescription {
224 + state: State::Progressing,
225 + sets,
226 + reps,
227 + load,
228 + }
229 + }
230 +
231 + #[test]
232 + fn format_prescription_freeweight() {
233 + let s = format_prescription(&ex("bench", ResistanceType::Freeweight, "kg"), &p(3, 5, 82.5));
234 + assert_eq!(s, "3 x 5 @ 82.5 kg");
235 + }
236 +
237 + #[test]
238 + fn format_prescription_trims_integer_loads() {
239 + let s = format_prescription(&ex("bench", ResistanceType::Freeweight, "kg"), &p(3, 5, 80.0));
240 + assert_eq!(s, "3 x 5 @ 80 kg");
241 + }
242 +
243 + #[test]
244 + fn format_prescription_bodyweight_zero_load() {
245 + let s = format_prescription(&ex("pullup", ResistanceType::Bodyweight, "kg"), &p(3, 8, 0.0));
246 + assert_eq!(s, "3 x 8");
247 + }
248 +
249 + #[test]
250 + fn format_prescription_bodyweight_with_added_load() {
251 + let s = format_prescription(&ex("pullup", ResistanceType::Bodyweight, "kg"), &p(3, 5, 10.0));
252 + assert_eq!(s, "3 x 5 + 10 kg");
253 + }
254 +
255 + #[test]
256 + fn format_prescription_cardio() {
257 + let s = format_prescription(&ex("row", ResistanceType::CardioTime, "min"), &p(1, 1, 12.0));
258 + assert_eq!(s, "12 min");
259 + }
260 +
261 + #[test]
262 + fn render_json_shape_is_stable() {
263 + let s = SessionExport {
264 + profile: "self".into(),
265 + date: NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(),
266 + warmup: vec!["chest".into(), "shoulders".into()],
267 + slots: vec![SlotExport {
268 + name: "bench".into(),
269 + prescription: "3 x 5 @ 82.5 kg".into(),
270 + glyph: "up".into(),
271 + }],
272 + };
273 + let expected = r#"{"profile":"self","date":"2026-07-18","warmup":["chest","shoulders"],"slots":[{"name":"bench","prescription":"3 x 5 @ 82.5 kg","glyph":"up"}]}"#;
274 + assert_eq!(render_json(&s), expected);
275 + }
276 +
277 + #[test]
278 + fn render_json_escapes_specials() {
279 + let s = SessionExport {
280 + profile: "alice \"the\" trainer".into(),
281 + date: NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(),
282 + warmup: vec![],
283 + slots: vec![SlotExport {
284 + name: "back\\slash".into(),
285 + prescription: "1 x 1".into(),
286 + glyph: "".into(),
287 + }],
288 + };
289 + let out = render_json(&s);
290 + assert!(out.contains(r#"\"the\""#));
291 + assert!(out.contains(r#"back\\slash"#));
292 + }
293 +
294 + #[test]
295 + fn export_returns_typst_missing_when_absent() {
296 + // typst is not installed in test env; ensure we surface a clear
297 + // error rather than a raw io::Error. If typst IS installed, this
298 + // test correctly fails — remove the guard or install per-CI norms.
299 + if std::process::Command::new("which")
300 + .arg("typst")
301 + .output()
302 + .ok()
303 + .and_then(|o| o.status.success().then_some(()))
304 + .is_some()
305 + {
306 + eprintln!("skipping: typst is on PATH in this environment");
307 + return;
308 + }
309 + let s = SessionExport {
310 + profile: "test".into(),
311 + date: NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(),
312 + warmup: vec![],
313 + slots: vec![],
314 + };
315 + let dir = std::env::temp_dir();
316 + let err = export_to_pdf(&s, &dir).unwrap_err();
317 + assert!(matches!(err, ExportError::TypstMissing));
318 + }
319 + }
@@ -22,6 +22,8 @@ use ripgrow_core::{
22 22 Db, Exercise, Prescription, PrescriptionResult, Readiness, State, Tag,
23 23 };
24 24
25 + use crate::pdf::{self, SessionExport, SlotExport};
26 +
25 27 pub struct GenerateScreen {
26 28 date: NaiveDate,
27 29 exercises: Vec<Exercise>,
@@ -218,13 +220,77 @@ impl GenerateScreen {
218 220 }
219 221 }
220 222 (KeyCode::Char('a'), _) => self.add_slot(db),
221 - (KeyCode::Char('e'), _) => {
222 - self.status = "PDF export lands in the next slice".to_string();
223 - }
223 + (KeyCode::Char('e'), _) => self.export_pdf(db),
224 224 _ => {}
225 225 }
226 226 }
227 227
228 + fn export_pdf(&mut self, db: &Db) {
229 + let Some(p) = self.preview.as_ref() else { return };
230 + if p.slots.is_empty() {
231 + self.status = "no slots to export".to_string();
232 + return;
233 + }
234 +
235 + let warmup_ids = warmup_tag_ids(
236 + &p.slots
237 + .iter()
238 + .map(|s| PickedSlot {
239 + primary: s.current().clone(),
240 + alternates: vec![],
241 + })
242 + .collect::<Vec<_>>(),
243 + );
244 + let warmup: Vec<String> = warmup_ids
245 + .iter()
246 + .filter_map(|id| self.tags.iter().find(|t| t.id == *id).map(|t| t.name.clone()))
247 + .collect();
248 +
249 + let mut slots: Vec<SlotExport> = Vec::new();
250 + for slot in &p.slots {
251 + let ex = slot.current();
252 + let (prescription, glyph) = match db.compute_prescription(ex.id) {
253 + Ok(PrescriptionResult::Prescribed(pres)) => {
254 + let text = pdf::format_prescription(ex, &pres);
255 + let g = direction_glyph(db, ex, pres.load);
256 + (text, g.to_string())
257 + }
258 + Ok(PrescriptionResult::NoHistory) => {
259 + ("seed on first log".to_string(), String::new())
260 + }
261 + Err(e) => {
262 + self.status = format!("prescription failed: {e}");
263 + return;
264 + }
265 + };
266 + slots.push(SlotExport {
267 + name: ex.name.clone(),
268 + prescription,
269 + glyph,
270 + });
271 + }
272 +
273 + let session = SessionExport {
274 + profile: db
275 + .profile_name()
276 + .ok()
277 + .flatten()
278 + .unwrap_or_else(|| "profile".to_string()),
279 + date: self.date,
280 + warmup,
281 + slots,
282 + };
283 +
284 + let output_dir = dirs::download_dir()
285 + .or_else(dirs::home_dir)
286 + .unwrap_or_else(std::env::temp_dir);
287 +
288 + match pdf::export_to_pdf(&session, &output_dir) {
289 + Ok(path) => self.status = format!("exported: {}", path.display()),
290 + Err(e) => self.status = format!("export failed: {e}"),
291 + }
292 + }
293 +
228 294 fn add_slot(&mut self, db: &Db) {
229 295 let Some(p) = self.preview.as_mut() else { return };
230 296 let days = db.days_since_last_map(self.date).unwrap_or_default();
@@ -0,0 +1,44 @@
1 + // ripgrow session sheet. Rendered via `typst compile --input
2 + // data-path=<path> sheet.typ output.pdf` where <path> points at a JSON file
3 + // with shape:
4 + //
5 + // {
6 + // "profile": "self",
7 + // "date": "2026-07-18",
8 + // "warmup": ["chest", "shoulders"],
9 + // "slots": [
10 + // {"name": "bench", "prescription": "3 x 5 @ 82 kg", "glyph": "up"},
11 + // ...
12 + // ]
13 + // }
14 +
15 + #let data = json(sys.inputs.at("data-path"))
16 +
17 + #set page(paper: "us-letter", margin: 0.75in)
18 + #set text(font: "Helvetica", size: 11pt)
19 +
20 + #align(center)[
21 + #text(size: 16pt, weight: "bold")[ripgrow]
22 + #v(0.2em)
23 + #text(size: 10pt)[#data.profile — #data.date]
24 + ]
25 +
26 + #v(1em)
27 +
28 + #if data.warmup.len() > 0 [
29 + *warmup:* #data.warmup.join(", ")
30 + ] else [
31 + *warmup:* —
32 + ]
33 +
34 + #v(1em)
35 +
36 + #for (i, slot) in data.slots.enumerate() [
37 + #text(weight: "bold")[#(i + 1). #slot.name]
38 + #h(1em)
39 + #slot.prescription
40 + #h(1em)
41 + #if slot.glyph != "" [ #text(style: "italic")[#slot.glyph] ]
42 +
43 + #v(0.5em)
44 + ]