1use crate::error::{Error, Result};
48use std::fs;
49use std::path::{Path, PathBuf};
50
51pub const PLUGIN_VERSION: &str = "0.1.0";
52
53#[derive(Debug, Clone)]
55pub struct GenerateConfig {
56 pub source_file: PathBuf,
58 pub output_dir: Option<PathBuf>,
60}
61
62pub fn generate_temp_file(config: &GenerateConfig) -> Result<PathBuf> {
82 if !config.source_file.exists() {
84 return Err(Error::custom(format!(
85 "Source file does not exist: {}",
86 config.source_file.display()
87 )));
88 }
89
90 let output_dir = match &config.output_dir {
92 Some(dir) => dir.clone(),
93 None => {
94 let parent = config
95 .source_file
96 .parent()
97 .ok_or_else(|| Error::custom("Source file has no parent directory".to_string()))?;
98 parent.join(".ankify")
99 }
100 };
101
102 if !output_dir.exists() {
104 fs::create_dir_all(&output_dir).map_err(|e| {
105 Error::custom(format!(
106 "Failed to create output directory {}: {}",
107 output_dir.display(),
108 e
109 ))
110 })?;
111 }
112
113 let source_stem = config
115 .source_file
116 .file_stem()
117 .and_then(|s| s.to_str())
118 .ok_or_else(|| Error::custom("Invalid source file name".to_string()))?;
119
120 let relative_source_path = get_relative_path(&output_dir, &config.source_file)?;
122
123 let temp_file_path = output_dir.join(format!("{}_render.typ", source_stem));
125
126 let typst_content = generate_typst_content(&relative_source_path)?;
128
129 fs::write(&temp_file_path, typst_content).map_err(|e| {
131 Error::custom(format!(
132 "Failed to write temporary file {}: {}",
133 temp_file_path.display(),
134 e
135 ))
136 })?;
137
138 Ok(temp_file_path)
139}
140
141fn generate_typst_content(relative_source_path: &str) -> Result<String> {
147 if relative_source_path.is_empty() {
149 return Err(Error::custom(
150 "Relative source path cannot be empty".to_string(),
151 ));
152 }
153
154 const TEMPLATE: &str = r#"#import "<<SOURCE>>" as __ankify-source-file
155#import "@preview/ankify:<<VERSION>>": __ankify-configuration, __ankify-notes
156
157// Default geometry for the rendered card images. The user's `setup` function
158// (if any) is applied below and may override this.
159#set page(width: 105mm, height: auto, margin: 5mm)
160
161#context {
162 let config = __ankify-configuration.final()
163 let notes = __ankify-notes.final()
164
165 let raw-setup = config.at("setup", default: none)
166 let setup = if raw-setup == none { (body) => body } else { raw-setup }
167
168 // Flatten every note's fields into a single ordered list. Notes keep their
169 // document order; fields within a note are sorted by name. The CLI walks
170 // fields in this exact same order, so list index I corresponds to page I+1.
171 let items = ()
172 for note in notes {
173 for (field, value) in note.data.pairs().sorted() {
174 let field-content = if type(value) == dictionary and "value" in value {
175 value.value
176 } else if type(value) in (content, str) {
177 value
178 } else {
179 panic("Invalid type for note data field: " + field)
180 }
181 items.push((note: note, field: field, content: field-content))
182 }
183 }
184
185 // Render one field per page, sizing each page snugly to its content so the
186 // resulting card image has no superfluous whitespace. Content wider than
187 // `max-width` wraps instead of producing an arbitrarily wide image; the whole
188 // card is then enlarged by the configured `scale` factor. The page has no
189 // fill, so the rendered card is transparent and the Anki card's own (themed)
190 // background shows through.
191 let max-width = 14cm
192 let card-margin = 5mm
193 let card-scale = config.at("scale", default: 1.5) * 100%
194 setup({
195 for (i, item) in items.enumerate() {
196 let body = (item.note.render)(
197 note: item.note,
198 field: item.field,
199 field-content: item.content,
200 )
201 let w = calc.min(measure(body).width, max-width)
202 let card = scale(card-scale, reflow: true, block(width: w, body))
203 set page(
204 width: w * card-scale + 2 * card-margin,
205 height: auto,
206 margin: card-margin,
207 fill: none,
208 )
209 card
210 if i != items.len() - 1 {
211 pagebreak(weak: false)
212 }
213 }
214 })
215}
216
217// Re-run the source document so that its `note()`/`configure()` calls register
218// their state. It is rendered hidden, on trailing pages that the CLI ignores
219// (the CLI only consumes the first N pages, one per note field).
220#pagebreak(weak: false)
221#set page(width: 210mm, height: 297mm, margin: 20mm)
222#hide([#__ankify-source-file])
223"#;
224
225 let content = TEMPLATE
226 .replace("<<SOURCE>>", relative_source_path)
227 .replace("<<VERSION>>", PLUGIN_VERSION);
228
229 let content = if std::env::var("ANKIFY_USE_LOCAL_IMPORTS").is_ok() {
234 content.replace(
235 &format!("@preview/ankify:{}", PLUGIN_VERSION),
236 &format!("@local/ankify:{}", PLUGIN_VERSION),
237 )
238 } else {
239 content
240 };
241
242 Ok(content)
243}
244
245fn get_relative_path(from_dir: &Path, to_file: &Path) -> Result<String> {
247 let from_abs = from_dir.canonicalize().map_err(|e| {
249 Error::custom(format!(
250 "Failed to canonicalize from directory {}: {}",
251 from_dir.display(),
252 e
253 ))
254 })?;
255
256 let to_abs = to_file.canonicalize().map_err(|e| {
257 Error::custom(format!(
258 "Failed to canonicalize to file {}: {}",
259 to_file.display(),
260 e
261 ))
262 })?;
263
264 let relative = pathdiff::diff_paths(&to_abs, &from_abs)
266 .ok_or_else(|| Error::custom("Failed to calculate relative path".to_string()))?;
267
268 let relative_str = relative
270 .to_str()
271 .ok_or_else(|| Error::custom("Relative path contains invalid UTF-8".to_string()))?
272 .replace('\\', "/"); Ok(relative_str)
275}
276
277pub fn cleanup_temp_files(dir: &Path) -> Result<()> {
290 if !dir.exists() {
291 return Ok(()); }
293
294 let entries = fs::read_dir(dir)
295 .map_err(|e| Error::custom(format!("Failed to read directory {}: {}", dir.display(), e)))?;
296
297 for entry in entries {
298 let entry = entry.map_err(|e| {
299 Error::custom(format!(
300 "Failed to read directory entry in {}: {}",
301 dir.display(),
302 e
303 ))
304 })?;
305
306 let path = entry.path();
307 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
308 if filename.ends_with("_render.typ") {
309 fs::remove_file(&path).map_err(|e| {
310 Error::custom(format!(
311 "Failed to remove temporary file {}: {}",
312 path.display(),
313 e
314 ))
315 })?;
316 }
317 }
318 }
319
320 Ok(())
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
332 fn template_renders_fields_before_source() {
333 let content = generate_typst_content("../notes.typ").unwrap();
334 let fields_pos = content
335 .find("__ankify-notes.final()")
336 .expect("field rendering should be present");
337 let source_pos = content
338 .find("#hide([#__ankify-source-file])")
339 .expect("source rendering should be present");
340 assert!(
341 fields_pos < source_pos,
342 "note fields must be rendered before the source document"
343 );
344 }
345
346 #[test]
347 fn template_substitutes_all_placeholders() {
348 let content = generate_typst_content("../notes.typ").unwrap();
349 assert!(content.contains("../notes.typ"));
350 assert!(!content.contains("<<SOURCE>>"));
351 assert!(!content.contains("<<VERSION>>"));
352 }
353
354 #[test]
355 fn empty_source_path_is_rejected() {
356 assert!(generate_typst_content("").is_err());
357 }
358}