|
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 |
+ |
}
|