Skip to main content

ankify/
generate.rs

1//! This module is responsible for generating temporary Typst files that will
2//! then be compiled by the `compile` module.
3//!
4//! The generated file imports the user's source document and the `ankify`
5//! Typst package, then renders every note field — one per page — so that the
6//! `compile` module can turn each page into an image.
7//!
8//! The crucial invariant is *page ordering*: page `N` must contain field `N`
9//! (counting notes in document order, and fields within a note in alphabetical
10//! order). The `compile` module relies on this 1:1 mapping.
11//!
12//! To uphold that invariant, the note fields are rendered *first* (pages
13//! `1..=N`), and only afterwards is the source document itself re-run — hidden,
14//! on trailing pages that the CLI ignores. Re-running the source is what makes
15//! the `note()`/`configure()` calls register their state; reading `.final()`
16//! before those trailing pages still observes every update, because `.final()`
17//! always yields the end-of-document value regardless of where it is read.
18//!
19//! Rendering the source last (instead of first, hidden, as an earlier version
20//! did) matters because a real lecture document produces an unpredictable
21//! number of pages — putting it first would shift every field's page number.
22//!
23//! The generated file looks like this:
24//!
25//! ```typst
26//! #import "../source.typ" as __ankify-source-file
27//! #import "@preview/ankify:0.1.0": __ankify-configuration, __ankify-notes
28//!
29//! #set page(width: 105mm, height: auto, margin: 5mm)
30//!
31//! #context {
32//!   let config = __ankify-configuration.final()
33//!   let notes = __ankify-notes.final()
34//!   // ... flatten fields, then render one per page ...
35//! }
36//!
37//! #pagebreak(weak: false)
38//! #set page(width: 210mm, height: 297mm, margin: 20mm)
39//! #hide([#__ankify-source-file])
40//! ```
41//!
42//! Note that importing the source file from the parent directory requires the
43//! compilation to use a `--root` flag covering both the temp file and the
44//! source. The `__ankify-source-file` import path is relative to the temp
45//! file's location, so it depends on the source file's name and directory.
46
47use crate::error::{Error, Result};
48use std::fs;
49use std::path::{Path, PathBuf};
50
51pub const PLUGIN_VERSION: &str = "0.1.0";
52
53/// Configuration for generating temporary Typst files.
54#[derive(Debug, Clone)]
55pub struct GenerateConfig {
56    /// The path to the source Typst file.
57    pub source_file: PathBuf,
58    /// The output directory for temporary files (optional, defaults to .ankify in source directory).
59    pub output_dir: Option<PathBuf>,
60}
61
62/// Generate a temporary Typst file for rendering note fields.
63///
64/// This function creates a temporary Typst file that imports the source file
65/// and uses the ankify plugin to extract and render note fields.
66///
67/// # Arguments
68///
69/// * `config` - Configuration for file generation
70///
71/// # Returns
72///
73/// Returns the path to the generated temporary file.
74///
75/// # Errors
76///
77/// Returns an error if:
78/// - The source file doesn't exist
79/// - The output directory cannot be created
80/// - The temporary file cannot be written
81pub fn generate_temp_file(config: &GenerateConfig) -> Result<PathBuf> {
82    // Validate source file exists
83    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    // Determine output directory
91    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    // Create output directory if it doesn't exist
103    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    // Generate the source file stem for the import
114    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    // Generate the relative path from output directory to source file
121    let relative_source_path = get_relative_path(&output_dir, &config.source_file)?;
122
123    // Generate temporary file path
124    let temp_file_path = output_dir.join(format!("{}_render.typ", source_stem));
125
126    // Generate the Typst content
127    let typst_content = generate_typst_content(&relative_source_path)?;
128
129    // Write the temporary file
130    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
141/// Generate the Typst content for the temporary file.
142///
143/// The template uses `<<SOURCE>>` and `<<VERSION>>` placeholders rather than
144/// `format!` interpolation so that the (brace-heavy) Typst code can be written
145/// literally, without escaping every `{` and `}`.
146fn generate_typst_content(relative_source_path: &str) -> Result<String> {
147    // Validate inputs
148    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    // In tests the `ankify` Typst package is not published to the registry, so
230    // it is installed into a local package directory (pointed at by the
231    // TYPST_PACKAGE_PATH env var) and the import is rewritten from the
232    // `@preview` namespace to `@local`.
233    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
245/// Get the relative path from one directory to another file.
246fn get_relative_path(from_dir: &Path, to_file: &Path) -> Result<String> {
247    // Convert to absolute paths to handle edge cases
248    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    // Calculate relative path
265    let relative = pathdiff::diff_paths(&to_abs, &from_abs)
266        .ok_or_else(|| Error::custom("Failed to calculate relative path".to_string()))?;
267
268    // Convert to string and normalize path separators
269    let relative_str = relative
270        .to_str()
271        .ok_or_else(|| Error::custom("Relative path contains invalid UTF-8".to_string()))?
272        .replace('\\', "/"); // Normalize to forward slashes for Typst
273
274    Ok(relative_str)
275}
276
277/// Clean up temporary files in the given directory.
278///
279/// This function removes all files matching the pattern `*_render.typ` in the
280/// specified directory.
281///
282/// # Arguments
283///
284/// * `dir` - The directory to clean up
285///
286/// # Errors
287///
288/// Returns an error if the directory cannot be read or files cannot be removed.
289pub fn cleanup_temp_files(dir: &Path) -> Result<()> {
290    if !dir.exists() {
291        return Ok(()); // Nothing to clean up
292    }
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    /// The note fields must be rendered *before* the source document so that
328    /// output page N maps to note field N. Rendering the source first would
329    /// shift every field's page number by the (unpredictable) source page
330    /// count — the exact bug this ordering guards against.
331    #[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}