Skip to main content

max / ripgrow

19.2 KB · 571 lines History Blame Raw
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::{
14 AnyPrescription, DistancePrescription, Exercise, Prescription, ResistanceType,
15 TimedPrescription,
16 };
17
18 const TEMPLATE: &str = include_str!("../templates/sheet.typ");
19
20 #[derive(Debug, Clone, PartialEq)]
21 pub struct SessionExport {
22 pub profile: String,
23 pub date: NaiveDate,
24 pub warmup: Vec<String>,
25 pub slots: Vec<SlotExport>,
26 }
27
28 #[derive(Debug, Clone, PartialEq)]
29 pub struct SlotExport {
30 pub name: String,
31 /// Set count as a string. `"-"` when the exercise is cardio and set
32 /// count doesn't apply.
33 pub sets: String,
34 /// Target reps per set. `"-"` for cardio.
35 pub reps: String,
36 /// Load number as a string. `"BW"` for bodyweight-only exercises,
37 /// `"BW+10"` for bodyweight with added load, `"82.5"` for freeweight
38 /// or cardio.
39 pub load: String,
40 /// Unit for the load, if any. Empty string for bodyweight-only.
41 pub unit: String,
42 /// `"up"` / `"flat"` / `"down"` or empty when there's no prior session.
43 pub glyph: String,
44 }
45
46 /// Decomposed prescription, ready for a tabular layout.
47 #[derive(Debug, Clone, PartialEq)]
48 pub struct PrescriptionParts {
49 pub sets: String,
50 pub reps: String,
51 pub load: String,
52 pub unit: String,
53 }
54
55 impl PrescriptionParts {
56 /// Reps-shaped prescription. Bodyweight renders as `BW` (or `BW+N`
57 /// for weighted bodyweight); everything else prints the numeric
58 /// load with the exercise's load unit.
59 pub fn from(ex: &Exercise, p: &Prescription) -> Self {
60 match ex.resistance_type {
61 ResistanceType::Bodyweight if p.load.get() == 0.0 => Self {
62 sets: p.sets.to_string(),
63 reps: p.reps.to_string(),
64 load: "BW".to_string(),
65 unit: String::new(),
66 },
67 ResistanceType::Bodyweight => Self {
68 sets: p.sets.to_string(),
69 reps: p.reps.to_string(),
70 load: format!("BW+{}", trim(p.load.get())),
71 unit: ex.load_unit.as_str().to_string(),
72 },
73 ResistanceType::CardioTime | ResistanceType::CardioDistance => {
74 // Cardio exercises route through TimedKind / DistanceKind
75 // now, so this branch is unreachable for real callers.
76 // Kept defensive rather than panicking on an exotic mix
77 // (e.g. a manually-crafted Prescription for a cardio ex).
78 Self::no_history()
79 }
80 ResistanceType::Freeweight | ResistanceType::Machine => Self {
81 sets: p.sets.to_string(),
82 reps: p.reps.to_string(),
83 load: trim(p.load.get()),
84 unit: ex.load_unit.as_str().to_string(),
85 },
86 }
87 }
88
89 /// Timed prescription — plank holds and the like. Reps slot reads
90 /// `-` (a timed set has no rep count); the duration lands in the
91 /// load column with its own display (`1m30s`) and the unit column
92 /// stays empty.
93 pub fn from_timed(_ex: &Exercise, p: &TimedPrescription) -> Self {
94 Self {
95 sets: p.sets.to_string(),
96 reps: "-".to_string(),
97 load: p.duration.to_string(),
98 unit: String::new(),
99 }
100 }
101
102 /// Distance prescription. Distance is track-only today
103 /// (`DistanceKind::compute_prescription` always returns `NoHistory`),
104 /// so this factory exists purely for exhaustiveness at the dispatch
105 /// boundary — it renders the same as [`Self::no_history`].
106 pub fn from_distance(_ex: &Exercise, _p: &DistancePrescription) -> Self {
107 Self::no_history()
108 }
109
110 /// Dispatch on kind. The reps-shaped branch is the only one that
111 /// currently distinguishes bodyweight from freeweight; timed and
112 /// distance render kind-uniformly.
113 pub fn from_any(ex: &Exercise, p: &AnyPrescription) -> Self {
114 match p {
115 AnyPrescription::Reps(p) => Self::from(ex, p),
116 AnyPrescription::Timed(p) => Self::from_timed(ex, p),
117 AnyPrescription::Distance(p) => Self::from_distance(ex, p),
118 }
119 }
120
121 /// "Seed on first log" placeholder for any kind's `NoHistory` case.
122 /// The Generate screen uses this so the PDF and the on-screen
123 /// preview render the same slot when there's nothing to prescribe.
124 pub fn no_history() -> Self {
125 Self {
126 sets: "-".to_string(),
127 reps: "-".to_string(),
128 load: "seed".to_string(),
129 unit: String::new(),
130 }
131 }
132 }
133
134 #[derive(Debug, thiserror::Error)]
135 pub enum ExportError {
136 #[error("typst not on PATH — install it (e.g. `brew install typst`)")]
137 TypstMissing,
138 #[error("typst exited with status {status}: {stderr}")]
139 TypstFailed { status: i32, stderr: String },
140 #[error("io: {0}")]
141 Io(#[from] std::io::Error),
142 }
143
144 /// Render `session` to a PDF and return the output path. `output_dir` must
145 /// exist and be writable; the file lands there as
146 /// `ripgrow-<profile-slug>-<date>.pdf`.
147 pub fn export_to_pdf(
148 session: &SessionExport,
149 output_dir: &std::path::Path,
150 ) -> Result<PathBuf, ExportError> {
151 fs::create_dir_all(output_dir)?;
152 let scratch = scratch_dir()?;
153 let data_path = scratch.join("data.json");
154 let template_path = scratch.join("sheet.typ");
155 let output_path = output_dir.join(format!(
156 "ripgrow-{}-{}.pdf",
157 slugify(&session.profile),
158 session.date.format("%Y-%m-%d")
159 ));
160
161 fs::write(&data_path, render_json(session))?;
162 fs::write(&template_path, TEMPLATE)?;
163
164 // Typst confines file reads to the template's root and treats any path
165 // passed via --input as relative to it, so hand the template a bare
166 // filename; both live in the same scratch dir.
167 let status = Command::new("typst")
168 .arg("compile")
169 .arg("--input")
170 .arg("data-path=data.json")
171 .arg(&template_path)
172 .arg(&output_path)
173 .output();
174
175 let output = match status {
176 Ok(o) => o,
177 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
178 return Err(ExportError::TypstMissing);
179 }
180 Err(e) => return Err(ExportError::Io(e)),
181 };
182
183 if !output.status.success() {
184 return Err(ExportError::TypstFailed {
185 status: output.status.code().unwrap_or(-1),
186 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
187 });
188 }
189
190 // Best-effort scratch cleanup; ignore errors.
191 let _ = fs::remove_dir_all(&scratch);
192
193 Ok(output_path)
194 }
195
196 /// Format the numeric prescription as a single line for the on-screen
197 /// preview. The PDF template consumes the decomposed [`PrescriptionParts`]
198 /// instead so the tabular layout can align columns.
199 pub fn format_prescription(ex: &Exercise, p: &Prescription) -> String {
200 let parts = PrescriptionParts::from(ex, p);
201 let (sets, reps, load, unit) = (parts.sets, parts.reps, parts.load, parts.unit);
202 match ex.resistance_type {
203 ResistanceType::Bodyweight if p.load.get() == 0.0 => format!("{sets} x {reps}"),
204 ResistanceType::Bodyweight => format!("{sets} x {reps} {load} {unit}"),
205 ResistanceType::CardioTime | ResistanceType::CardioDistance => {
206 format!("{load} {unit}")
207 }
208 ResistanceType::Freeweight | ResistanceType::Machine => {
209 format!("{sets} x {reps} @ {load} {unit}")
210 }
211 }
212 }
213
214 /// Drop trailing zeros from a float when it prints cleanly as an integer
215 /// (`80.0` -> `80`). Otherwise defer to the default `{}` formatting.
216 fn trim(load: f64) -> String {
217 if (load - load.round()).abs() < 1e-9 {
218 format!("{}", load as i64)
219 } else {
220 format!("{load}")
221 }
222 }
223
224 fn render_json(session: &SessionExport) -> String {
225 let mut out = String::from("{");
226 out.push_str(&format!("\"profile\":{},", escape(&session.profile)));
227 out.push_str(&format!(
228 "\"date\":{},",
229 escape(&session.date.format("%Y-%m-%d").to_string())
230 ));
231 out.push_str("\"warmup\":[");
232 for (i, w) in session.warmup.iter().enumerate() {
233 if i > 0 {
234 out.push(',');
235 }
236 out.push_str(&escape(w));
237 }
238 out.push_str("],\"slots\":[");
239 for (i, s) in session.slots.iter().enumerate() {
240 if i > 0 {
241 out.push(',');
242 }
243 out.push('{');
244 out.push_str(&format!("\"name\":{},", escape(&s.name)));
245 out.push_str(&format!("\"sets\":{},", escape(&s.sets)));
246 out.push_str(&format!("\"reps\":{},", escape(&s.reps)));
247 out.push_str(&format!("\"load\":{},", escape(&s.load)));
248 out.push_str(&format!("\"unit\":{},", escape(&s.unit)));
249 out.push_str(&format!("\"glyph\":{}", escape(&s.glyph)));
250 out.push('}');
251 }
252 out.push_str("]}");
253 out
254 }
255
256 fn escape(s: &str) -> String {
257 let mut out = String::with_capacity(s.len() + 2);
258 out.push('"');
259 for c in s.chars() {
260 match c {
261 '"' => out.push_str("\\\""),
262 '\\' => out.push_str("\\\\"),
263 '\n' => out.push_str("\\n"),
264 '\r' => out.push_str("\\r"),
265 '\t' => out.push_str("\\t"),
266 c if (c as u32) < 0x20 => {
267 out.push_str(&format!("\\u{:04x}", c as u32));
268 }
269 c => out.push(c),
270 }
271 }
272 out.push('"');
273 out
274 }
275
276 fn slugify(name: &str) -> String {
277 // Same rules as ripgrow_core::slugify — duplicated here to keep this
278 // module dependency-light (no core cycle needed for one export path).
279 let mut out = String::with_capacity(name.len());
280 let mut prev_hyphen = true;
281 for c in name.chars() {
282 let ch = c.to_ascii_lowercase();
283 if ch.is_ascii_alphanumeric() {
284 out.push(ch);
285 prev_hyphen = false;
286 } else if !prev_hyphen {
287 out.push('-');
288 prev_hyphen = true;
289 }
290 }
291 while out.ends_with('-') {
292 out.pop();
293 }
294 if out.is_empty() {
295 "profile".to_string()
296 } else {
297 out
298 }
299 }
300
301 fn scratch_dir() -> Result<PathBuf, std::io::Error> {
302 let nanos = SystemTime::now()
303 .duration_since(UNIX_EPOCH)
304 .map(|d| d.as_nanos())
305 .unwrap_or(0);
306 let dir = std::env::temp_dir().join(format!("ripgrow-pdf-{}-{}", std::process::id(), nanos));
307 fs::create_dir_all(&dir)?;
308 Ok(dir)
309 }
310
311 #[cfg(test)]
312 mod tests {
313 use super::*;
314 use ripgrow_core::{EffortKind, LoadUnit, Prescription, ResistanceType, State};
315
316 fn ex(name: &str, rt: ResistanceType, unit: &str) -> Exercise {
317 Exercise {
318 id: 0,
319 name: name.to_string(),
320 resistance_type: rt,
321 effort_kind: EffortKind::Reps,
322 load_unit: LoadUnit::parse(unit).unwrap(),
323 increment: 2.5,
324 notes: String::new(),
325 tag_ids: vec![],
326 }
327 }
328
329 fn p(sets: i32, reps: i32, load: f64) -> Prescription {
330 Prescription {
331 state: State::Progressing,
332 sets,
333 reps: ripgrow_core::Reps::new(reps).unwrap(),
334 load: ripgrow_core::Load::new(load).unwrap(),
335 }
336 }
337
338 #[test]
339 fn parts_freeweight() {
340 let parts = PrescriptionParts::from(
341 &ex("bench", ResistanceType::Freeweight, "kg"),
342 &p(3, 5, 82.5),
343 );
344 assert_eq!(parts.sets, "3");
345 assert_eq!(parts.reps, "5");
346 assert_eq!(parts.load, "82.5");
347 assert_eq!(parts.unit, "kg");
348 }
349
350 #[test]
351 fn parts_trims_integer_loads() {
352 let parts = PrescriptionParts::from(
353 &ex("bench", ResistanceType::Freeweight, "kg"),
354 &p(3, 5, 80.0),
355 );
356 assert_eq!(parts.load, "80");
357 }
358
359 #[test]
360 fn parts_bodyweight_zero_load() {
361 let parts = PrescriptionParts::from(
362 &ex("pullup", ResistanceType::Bodyweight, "kg"),
363 &p(3, 8, 0.0),
364 );
365 assert_eq!(parts.load, "BW");
366 assert_eq!(parts.unit, "");
367 }
368
369 #[test]
370 fn parts_bodyweight_with_added_load() {
371 let parts = PrescriptionParts::from(
372 &ex("pullup", ResistanceType::Bodyweight, "kg"),
373 &p(3, 5, 10.0),
374 );
375 assert_eq!(parts.load, "BW+10");
376 assert_eq!(parts.unit, "kg");
377 }
378
379 #[test]
380 fn parts_timed_uses_duration_in_load_column() {
381 use ripgrow_core::{Duration, TimedPrescription};
382 let pres = TimedPrescription {
383 state: State::Progressing,
384 sets: 3,
385 duration: Duration::from_seconds(90).unwrap(),
386 };
387 let parts = PrescriptionParts::from_timed(
388 &ex("plank", ResistanceType::CardioTime, "kg"),
389 &pres,
390 );
391 assert_eq!(parts.sets, "3");
392 assert_eq!(parts.reps, "-");
393 assert_eq!(parts.load, "1m30s");
394 assert_eq!(parts.unit, "");
395 }
396
397 #[test]
398 fn no_history_renders_seed_placeholder() {
399 let parts = PrescriptionParts::no_history();
400 assert_eq!(parts.sets, "-");
401 assert_eq!(parts.reps, "-");
402 assert_eq!(parts.load, "seed");
403 assert_eq!(parts.unit, "");
404 }
405
406 #[test]
407 fn from_any_dispatches_by_kind() {
408 use ripgrow_core::{AnyPrescription, Duration, TimedPrescription};
409 let reps_parts = PrescriptionParts::from_any(
410 &ex("bench", ResistanceType::Freeweight, "kg"),
411 &AnyPrescription::Reps(p(3, 5, 82.5)),
412 );
413 assert_eq!(reps_parts.load, "82.5");
414 let timed_parts = PrescriptionParts::from_any(
415 &ex("plank", ResistanceType::CardioTime, "kg"),
416 &AnyPrescription::Timed(TimedPrescription {
417 state: State::Progressing,
418 sets: 3,
419 duration: Duration::from_seconds(60).unwrap(),
420 }),
421 );
422 assert_eq!(timed_parts.load, "1m00s");
423 assert_eq!(timed_parts.reps, "-");
424 }
425
426 #[test]
427 fn format_prescription_still_composes_a_one_liner() {
428 let s = format_prescription(&ex("bench", ResistanceType::Freeweight, "kg"), &p(3, 5, 82.5));
429 assert_eq!(s, "3 x 5 @ 82.5 kg");
430 }
431
432 #[test]
433 fn render_json_shape_is_stable() {
434 let s = SessionExport {
435 profile: "self".into(),
436 date: NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(),
437 warmup: vec!["chest".into(), "shoulders".into()],
438 slots: vec![SlotExport {
439 name: "bench".into(),
440 sets: "3".into(),
441 reps: "5".into(),
442 load: "82.5".into(),
443 unit: "kg".into(),
444 glyph: "up".into(),
445 }],
446 };
447 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"}]}"#;
448 assert_eq!(render_json(&s), expected);
449 }
450
451 #[test]
452 fn render_json_escapes_specials() {
453 let s = SessionExport {
454 profile: "alice \"the\" trainer".into(),
455 date: NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(),
456 warmup: vec![],
457 slots: vec![SlotExport {
458 name: "back\\slash".into(),
459 sets: "1".into(),
460 reps: "1".into(),
461 load: "0".into(),
462 unit: "".into(),
463 glyph: "".into(),
464 }],
465 };
466 let out = render_json(&s);
467 assert!(out.contains(r#"\"the\""#));
468 assert!(out.contains(r#"back\\slash"#));
469 }
470
471 fn typst_available() -> bool {
472 std::process::Command::new("typst")
473 .arg("--version")
474 .output()
475 .map(|o| o.status.success())
476 .unwrap_or(false)
477 }
478
479 #[test]
480 fn export_produces_pdf_end_to_end() {
481 if !typst_available() {
482 eprintln!("skipping: typst not on PATH");
483 return;
484 }
485 // Mixed-kind session: reps (freeweight + bodyweight), a timed
486 // slot with duration in the load column, and a distance slot in
487 // the seed-on-first-log state. Exercises the full PrescriptionParts
488 // shape space through the template.
489 let s = SessionExport {
490 profile: "self".into(),
491 date: NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(),
492 warmup: vec!["chest".into(), "shoulders".into(), "core".into()],
493 slots: vec![
494 SlotExport {
495 name: "bench".into(),
496 sets: "3".into(),
497 reps: "5".into(),
498 load: "82.5".into(),
499 unit: "kg".into(),
500 glyph: "up".into(),
501 },
502 SlotExport {
503 name: "pullup".into(),
504 sets: "3".into(),
505 reps: "8".into(),
506 load: "BW".into(),
507 unit: "".into(),
508 glyph: "flat".into(),
509 },
510 SlotExport {
511 name: "plank".into(),
512 sets: "3".into(),
513 reps: "-".into(),
514 load: "1m30s".into(),
515 unit: "".into(),
516 glyph: "up".into(),
517 },
518 SlotExport {
519 name: "5k run".into(),
520 sets: "-".into(),
521 reps: "-".into(),
522 load: "seed".into(),
523 unit: "".into(),
524 glyph: "".into(),
525 },
526 SlotExport {
527 name: "row \"paused\"".into(),
528 sets: "3".into(),
529 reps: "6".into(),
530 load: "60".into(),
531 unit: "kg".into(),
532 glyph: "".into(),
533 },
534 ],
535 };
536 let dir = std::env::temp_dir().join(format!(
537 "ripgrow-pdf-e2e-{}",
538 std::time::SystemTime::now()
539 .duration_since(std::time::UNIX_EPOCH)
540 .unwrap()
541 .as_nanos()
542 ));
543 std::fs::create_dir_all(&dir).unwrap();
544 let out = export_to_pdf(&s, &dir).unwrap();
545 assert!(out.exists(), "output PDF should exist at {}", out.display());
546 let bytes = std::fs::read(&out).unwrap();
547 assert!(bytes.starts_with(b"%PDF-"), "output should be a valid PDF");
548 let _ = std::fs::remove_dir_all(&dir);
549 }
550
551 #[test]
552 fn export_returns_typst_missing_when_absent() {
553 // Guards against a regression in the missing-binary error path.
554 // Skips when typst is present; the end-to-end test covers the
555 // happy path.
556 if typst_available() {
557 eprintln!("skipping: typst is on PATH in this environment");
558 return;
559 }
560 let s = SessionExport {
561 profile: "test".into(),
562 date: NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(),
563 warmup: vec![],
564 slots: vec![],
565 };
566 let dir = std::env::temp_dir();
567 let err = export_to_pdf(&s, &dir).unwrap_err();
568 assert!(matches!(err, ExportError::TypstMissing));
569 }
570 }
571