mdbook_numeq/
lib.rs

1//! An [mdBook](https://github.com/rust-lang/mdBook) preprocessor for automatically numbering centered equations.
2
3use log::warn;
4use mdbook_preprocessor::book::{Book, BookItem};
5use mdbook_preprocessor::errors::Result;
6use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
7use pathdiff::diff_paths;
8use regex::Regex;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12pub fn for_each_mut_ordered<'a, F, I>(func: &mut F, items: I)
13where
14    F: FnMut(&mut BookItem),
15    I: IntoIterator<Item = &'a mut BookItem>,
16{
17    for item in items {
18        func(item);
19        if let BookItem::Chapter(ch) = item {
20            for_each_mut_ordered(func, &mut ch.sub_items);
21        }
22    }
23}
24
25/// The preprocessor name.
26const NAME: &str = "numeq";
27
28/// A preprocessor for automatically numbering centered equations.
29#[derive(Default)]
30pub struct NumEqPreprocessor {
31    /// Whether equation numbers must be prefixed by the section number.
32    with_prefix: bool,
33    prefix_depth: usize,
34    global: bool,
35}
36
37/// The `LabelInfo` structure contains information for formatting the hyperlink to a specific equation.
38#[derive(Debug, PartialEq)]
39struct LabelInfo {
40    /// The number associated with the labeled equation.
41    num: String,
42    /// The path to the file containing the environment with the label.
43    path: PathBuf,
44}
45
46impl NumEqPreprocessor {
47    pub fn new(ctx: &PreprocessorContext) -> Self {
48        let mut preprocessor = Self::default();
49
50        if let Ok(Some(b)) = ctx.config.get::<bool>("preprocessor.numeq.prefix") {
51            preprocessor.with_prefix = b;
52        }
53
54        if let Ok(Some(d)) = ctx.config.get::<i32>("preprocessor.numeq.depth") {
55            if d > 0 {
56                preprocessor.prefix_depth = d as usize;
57            }
58        }
59
60        if let Ok(Some(b)) = ctx.config.get::<bool>("preprocessor.numeq.global") {
61            preprocessor.global = b;
62        }
63
64        preprocessor
65    }
66}
67
68impl Preprocessor for NumEqPreprocessor {
69    fn name(&self) -> &str {
70        NAME
71    }
72
73    fn run(&self, _ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
74        // a hashmap mapping labels to `LabelInfo` structs
75        let mut refs: HashMap<String, LabelInfo> = HashMap::new();
76        // equation counter
77        let mut ctr = 0;
78        // store current (sub-)chapter number according to the depth
79        // initialize with one 1 followed by (prefix_depth - 1) zeros
80        let mut ccn: Vec<usize> = vec![1];
81        ccn.resize(self.prefix_depth, 0);
82
83        book.for_each_chapter_mut(|chapter| {
84            // one can safely unwrap chapter.path which must be Some(...)
85            let mut prefix = if self.with_prefix {
86                match &chapter.number {
87                    Some(sn) => sn.to_string(),
88                    None => String::new(),
89                }
90            } else {
91                String::new()
92            };
93            let path = chapter.path.as_ref().unwrap();
94            // reset counter if global counting is set to false
95            if !self.global && self.prefix_depth == 0 {
96                ctr = 0;
97            }
98            if self.prefix_depth > 0 {
99                if prefix.is_empty() {
100                    // if prefix is empty, reset counter
101                    ctr = 0;
102                } else {
103                    // obtain the chapter number as vector of usize
104                    let mut prefix_vec: Vec<usize> = prefix
105                        .trim_end_matches('.')
106                        .split('.')
107                        .map(|s| s.parse::<usize>().unwrap())
108                        .collect::<Vec<usize>>();
109                    if prefix_vec.len() < self.prefix_depth {
110                        prefix_vec.resize(self.prefix_depth, 0);
111                    }
112                    // if ccn is different from the specifier in prefix_vec, update ccn
113                    if ccn[..] != prefix_vec[..self.prefix_depth] {
114                        ccn.copy_from_slice(&prefix_vec[..self.prefix_depth]);
115                        // reset counter
116                        ctr = 0;
117                    }
118                    // update prefix
119                    prefix = ccn
120                        .iter()
121                        .fold(String::new(), |acc, x| acc + &x.to_string() + ".");
122                }
123            }
124            chapter.content =
125                find_and_replace_eqs(&chapter.content, &prefix, path, &mut refs, &mut ctr);
126        });
127
128        book.for_each_chapter_mut(|chapter| {
129            // one can safely unwrap chapter.path which must be Some(...)
130            let path = chapter.path.as_ref().unwrap();
131            chapter.content = find_and_replace_refs(&chapter.content, path, &refs);
132        });
133
134        Ok(book)
135    }
136}
137
138/// Finds all patterns `{{numeq}}{mylabel}` (where `{mylabel}` is optional) and replaces them by `\label{mylabel} \tag{ctr}`;
139/// if a label is provided, updates the hashmap `refs` with an entry (label, LabelInfo) allowing to format links to the equation.
140fn find_and_replace_eqs(
141    s: &str,
142    prefix: &str,
143    path: &Path,
144    refs: &mut HashMap<String, LabelInfo>,
145    ctr: &mut usize,
146) -> String {
147    // see https://regex101.com/ for an explanation of the regex
148    let re: Regex = Regex::new(r"\{\{numeq\}\}(\{(?P<label>.*?)\})?").unwrap();
149
150    re.replace_all(s, |caps: &regex::Captures| {
151        *ctr += 1;
152        match caps.name("label") {
153            Some(lb) => {
154                // if a label is given, we must update the hashmap
155                let label = lb.as_str().to_string();
156                if refs.contains_key(&label) {
157                    // if the same label has already been used we emit a warning and don't update the hashmap
158                    warn!("Eq. {prefix}{ctr}: Label `{label}' already used");
159                } else {
160                    refs.insert(
161                        label.clone(),
162                        LabelInfo {
163                            num: format!("{prefix}{ctr}"),
164                            path: path.to_path_buf(),
165                        },
166                    );
167                }
168                format!("\\htmlId{{{label}}}{{}} \\tag{{{prefix}{ctr}}}")
169            }
170            None => {
171                format!("\\tag{{{prefix}{ctr}}}")
172            }
173        }
174    })
175    .to_string()
176}
177
178/// Finds and replaces all patterns {{eqref: label}} where label is an existing key in hashmap `refs`
179/// with link towards the relevant theorem.
180fn find_and_replace_refs(
181    s: &str,
182    chap_path: &PathBuf,
183    refs: &HashMap<String, LabelInfo>,
184) -> String {
185    // see https://regex101.com/ for an explanation of the regex
186    let re: Regex = Regex::new(r"\{\{eqref:\s*(?P<label>.*?)\}\}").unwrap();
187
188    re.replace_all(s, |caps: &regex::Captures| {
189        let label = caps.name("label").unwrap().as_str().to_string();
190        if refs.contains_key(&label) {
191            let text = &refs.get(&label).unwrap().num;
192            let path_to_ref = &refs.get(&label).unwrap().path;
193            let rel_path = compute_rel_path(chap_path, path_to_ref);
194            format!("[({text})]({rel_path}#{label})")
195        } else {
196            warn!("Unknown equation reference: {}", label);
197            "**[??]**".to_string()
198        }
199    })
200    .to_string()
201}
202
203/// Computes the relative path from the folder containing `chap_path` to the file `path_to_ref`.
204fn compute_rel_path(chap_path: &PathBuf, path_to_ref: &PathBuf) -> String {
205    if chap_path == path_to_ref {
206        return "".to_string();
207    }
208    let mut local_chap_path = chap_path.clone();
209    local_chap_path.pop();
210    format!(
211        "{}",
212        diff_paths(path_to_ref, &local_chap_path).unwrap().display()
213    )
214}
215
216#[cfg(test)]
217mod test {
218    use super::*;
219    use lazy_static::lazy_static;
220
221    const SECNUM: &str = "1.2.";
222
223    lazy_static! {
224        static ref PATH: PathBuf = "crypto/groups.md".into();
225    }
226
227    #[test]
228    fn no_label() {
229        let mut refs = HashMap::new();
230        let mut ctr = 0;
231        let input = String::from(r"{{numeq}}");
232        let output = find_and_replace_eqs(&input, SECNUM, &PATH, &mut refs, &mut ctr);
233        let expected = String::from("\\tag{1.2.1}");
234        assert_eq!(output, expected);
235        assert!(refs.is_empty());
236    }
237
238    #[test]
239    fn with_label() {
240        let mut refs = HashMap::new();
241        let mut ctr = 0;
242        let input = String::from(r"{{numeq}}{eq:test}");
243        let output = find_and_replace_eqs(&input, SECNUM, &PATH, &mut refs, &mut ctr);
244        let expected = String::from("\\htmlId{eq:test}{} \\tag{1.2.1}");
245        assert_eq!(output, expected);
246        assert_eq!(
247            *refs.get("eq:test").unwrap(),
248            LabelInfo {
249                num: "1.2.1".to_string(),
250                path: "crypto/groups.md".into(),
251            }
252        )
253    }
254}