Skip to main content

latex_rust/parser/
preproc.rs

1//! String-level LaTeX math sanitizer run before tokenization.
2//!
3//! Rewrites that preserve math meaning for a renderer: `{a \over b}` →
4//! `\frac`, `{n \choose k}` → `\binom`, `\tfrac`/`\dfrac` → `\frac`, plain-TeX
5//! font switches, `\mbox` → `\text`. Extensible `\left`/`\right`, skips, and
6//! accents are left intact.
7
8/// Normalize raw math input for the tokenizer.
9///
10/// Rewrites that preserve math meaning: `{a \over b}` → `\frac`, `{n \choose k}`
11/// → `\binom`, `\tfrac`/`\dfrac` → `\frac`, plain-TeX font switches, `\mbox` →
12/// `\text`.
13///
14/// # Examples
15///
16/// ```
17/// use latex_rust::preprocess;
18///
19/// assert!(preprocess(r"{a \over b}").contains(r"\frac"));
20/// ```
21#[must_use]
22pub fn preprocess(raw_input: &str) -> String {
23    let input = raw_input.trim().to_string();
24    if input.is_empty() {
25        return input;
26    }
27
28    let mut input = input;
29    input = input.replace(r"{ (}", "(").replace(r"{ )}", ")");
30    input = input.replace(r"{ ( }", "(").replace(r"{ ) }", ")");
31    input = input.replace(r"{[}", "[").replace(r"{]}", "]");
32
33    input = input
34        .replace(r"\tfrac", r"\frac")
35        .replace(r"\dfrac", r"\frac")
36        .replace(r"\cfrac", r"\frac");
37
38    input = convert_plain_tex_font_scopes(&input);
39    input = convert_over_fractions(&input);
40    input = convert_choose(&input);
41    input = input.replace(r"\mbox{", r"\text{");
42    input
43}
44
45fn convert_plain_tex_font_scopes(input: &str) -> String {
46    let map = [
47        (r"\rm", r"\mathrm"),
48        (r"\bf", r"\mathbf"),
49        (r"\cal", r"\mathcal"),
50        (r"\it", r"\mathit"),
51        (r"\sf", r"\mathsf"),
52        (r"\tt", r"\mathtt"),
53    ];
54    let mut result = input.to_string();
55    for (old, new) in map {
56        let mut search = 0;
57        while let Some(rel) = result[search..].find(old) {
58            let abs = search + rel;
59            let body = abs + old.len();
60            if abs > 0 && result.as_bytes()[abs - 1] == b'{' {
61                if let Some(close) = result[abs..].find('}') {
62                    let end = abs + close;
63                    let content = result[body..end].trim();
64                    let rep = format!("{new}{{{content}}}");
65                    result.replace_range((abs - 1)..=end, &rep);
66                    search = abs - 1 + rep.len();
67                    continue;
68                }
69            }
70            search = abs + old.len();
71        }
72    }
73    result
74}
75
76fn convert_over_fractions(input: &str) -> String {
77    let mut result = input.to_string();
78    while let Some(over) = result.find(r"\over") {
79        let mut open = None;
80        let mut depth = 0;
81        for (i, ch) in result[..over].char_indices().rev() {
82            match ch {
83                '}' => depth += 1,
84                '{' => {
85                    if depth == 0 {
86                        open = Some(i);
87                        break;
88                    }
89                    depth -= 1;
90                }
91                _ => {}
92            }
93        }
94        let mut close = None;
95        depth = 0;
96        for (i, ch) in result[over + 5..].char_indices() {
97            match ch {
98                '{' => depth += 1,
99                '}' => {
100                    if depth == 0 {
101                        close = Some(over + 5 + i);
102                        break;
103                    }
104                    depth -= 1;
105                }
106                _ => {}
107            }
108        }
109        if let (Some(s), Some(e)) = (open, close) {
110            let num = result[s + 1..over].trim();
111            let den = result[over + 5..e].trim();
112            let rep = format!(r"\frac{{{num}}}{{{den}}}");
113            result.replace_range(s..=e, &rep);
114        } else {
115            break;
116        }
117    }
118    result
119}
120
121/// `{n \choose k}` → `\binom{n}{k}` (same brace walk as `\over`).
122fn convert_choose(input: &str) -> String {
123    let mut result = input.to_string();
124    while let Some(ch) = result.find(r"\choose") {
125        let mut open = None;
126        let mut depth = 0;
127        for (i, c) in result[..ch].char_indices().rev() {
128            match c {
129                '}' => depth += 1,
130                '{' => {
131                    if depth == 0 {
132                        open = Some(i);
133                        break;
134                    }
135                    depth -= 1;
136                }
137                _ => {}
138            }
139        }
140        let mut close = None;
141        depth = 0;
142        for (i, c) in result[ch + 7..].char_indices() {
143            match c {
144                '{' => depth += 1,
145                '}' => {
146                    if depth == 0 {
147                        close = Some(ch + 7 + i);
148                        break;
149                    }
150                    depth -= 1;
151                }
152                _ => {}
153            }
154        }
155        if let (Some(s), Some(e)) = (open, close) {
156            let n = result[s + 1..ch].trim();
157            let k = result[ch + 7..e].trim();
158            let rep = format!(r"\binom{{{n}}}{{{k}}}");
159            result.replace_range(s..=e, &rep);
160        } else {
161            break;
162        }
163    }
164    result
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn left_right_kept() {
173        let s = preprocess(r"\left(\frac{1}{2}\right)");
174        assert!(s.contains(r"\left"));
175        assert!(s.contains(r"\right"));
176    }
177
178    #[test]
179    fn tfrac_becomes_frac() {
180        assert_eq!(preprocess(r"\tfrac{a}{b}"), r"\frac{a}{b}");
181    }
182
183    #[test]
184    fn over_becomes_frac() {
185        assert_eq!(preprocess(r"{a \over b}"), r"\frac{a}{b}");
186    }
187
188    #[test]
189    fn trailing_period_kept() {
190        assert_eq!(preprocess("x^2."), "x^2.");
191    }
192
193    #[test]
194    fn empty() {
195        assert_eq!(preprocess("   "), "");
196    }
197
198    #[test]
199    fn overline_kept() {
200        assert_eq!(preprocess(r"\overline{z}"), r"\overline{z}");
201    }
202
203    #[test]
204    fn choose_becomes_binom() {
205        assert_eq!(preprocess(r"{n \choose k}"), r"\binom{n}{k}");
206    }
207
208    #[test]
209    fn mbox_becomes_text() {
210        assert_eq!(preprocess(r"\mbox{Diagonal}"), r"\text{Diagonal}");
211    }
212
213    #[test]
214    fn spacing_kept() {
215        let s = preprocess(r"a\,b");
216        assert!(s.contains(r"\,"));
217    }
218}