rsmarkdown_core/
preprocess.rs1use crate::fix::*;
5
6#[derive(Debug, Clone, Copy, Default)]
7pub struct PreprocessOptions {
8 pub single_dollar_text_math: bool,
10}
11
12fn proprocess_content(content: &str) -> String {
14 if content.contains('\r') {
15 let no_cr = content.replace("\r\n", "\n").replace('\r', "\n");
16 no_cr.trim_end().to_string()
17 } else {
18 content.trim_end().to_string()
19 }
20}
21
22const CODE_BLOCK_PLACEHOLDER: &str = "CODE_BLOCK_PLACEHOLDER";
23const DOLLAR_PLACEHOLDER: &str = "_TMP_REPLACE_DOLLAR_";
24
25pub fn preprocess_latex(content: &str) -> String {
30 if !content.contains(['$', '\\']) {
32 return content.to_string();
33 }
34 let code_blocks: Vec<&str> = {
35 let mut blocks = Vec::new();
36 let ranges = crate::scan::find_closed_code_block_ranges(content);
37 for &(start, end) in &ranges {
38 blocks.push(&content[start..end]);
39 }
40 blocks
41 };
42 if code_blocks.is_empty() {
43 let mut p = content.to_string();
44 p = replace_bracket_math(&p, false);
45 p = replace_bracket_math(&p, true);
46 p = replace_paren_math(&p);
47 p = replace_dollar_math(&p);
48 return p;
49 }
50 let mut processed = {
51 let ranges = crate::scan::find_closed_code_block_ranges(content);
52 let mut out = String::with_capacity(content.len());
53 let mut cursor = 0;
54 for &(start, end) in &ranges {
55 out.push_str(&content[cursor..start]);
56 out.push_str(CODE_BLOCK_PLACEHOLDER);
57 cursor = end;
58 }
59 out.push_str(&content[cursor..]);
60 out
61 };
62
63 processed = replace_bracket_math(&processed, false);
64 processed = replace_bracket_math(&processed, true);
65 processed = replace_paren_math(&processed);
66 processed = replace_dollar_math(&processed);
67
68 let mut restored = String::with_capacity(processed.len());
70 let mut ph_pos = 0;
71 let mut block_idx = 0;
72 while let Some(rel) = memchr::memmem::find(&processed.as_bytes()[ph_pos..], CODE_BLOCK_PLACEHOLDER.as_bytes()) {
73 restored.push_str(&processed[ph_pos..ph_pos + rel]);
74 restored.push_str(&code_blocks[block_idx].replace('$', DOLLAR_PLACEHOLDER));
75 ph_pos += rel + CODE_BLOCK_PLACEHOLDER.len();
76 block_idx += 1;
77 }
78 restored.push_str(&processed[ph_pos..]);
79
80 restored.replace(DOLLAR_PLACEHOLDER, "$")
81}
82
83fn replace_bracket_math(content: &str, multiline: bool) -> String {
84 let mut out = String::with_capacity(content.len());
86 let mut i = 0;
87 while i < content.len() {
88 let ch = content[i..].chars().next().expect("char at boundary");
89 if ch == '\\' && content[i + 1..].starts_with('[') {
90 let rest = &content[i + 2..];
91 if let Some(rel) = memchr::memmem::find(rest.as_bytes(), b"\\]") {
92 let equation = &rest[..rel];
93 let span_len = equation.len();
94 if (multiline || !equation.contains('\n')) && (span_len > 0 || equation.is_empty())
95 {
96 out.push_str("$$");
97 out.push_str(equation);
98 out.push_str("$$");
99 i += 2 + span_len + 2;
100 continue;
101 }
102 }
103 }
104 out.push(ch);
105 i += ch.len_utf8();
106 }
107 out
108}
109
110fn replace_paren_math(content: &str) -> String {
111 let mut out = String::with_capacity(content.len());
112 let mut i = 0;
113 while let Some(rel) = memchr::memmem::find(&content.as_bytes()[i..], b"\\(") {
114 let open = i + rel;
115 let rest = &content[open + 2..];
116 let Some(close_rel) = memchr::memmem::find(rest.as_bytes(), b"\\)") else {
117 break;
118 };
119 let equation = &rest[..close_rel];
120 if equation.contains('\n') {
121 break;
122 }
123 out.push_str(&content[i..open]);
124 out.push_str("$$");
125 out.push_str(equation);
126 out.push_str("$$");
127 i = open + 2 + equation.len() + 2;
128 }
129 out.push_str(&content[i..]);
130 out
131}
132
133fn replace_dollar_math(content: &str) -> String {
139 let bytes = content.as_bytes();
140 let mut out = String::with_capacity(content.len());
141 let mut i = 0;
142 let mut span_start = 0;
143 while i < bytes.len() {
144 let prefix_ok = i == 0 || bytes[i - 1] != b'\\';
145 let part_of_double = bytes.get(i + 1) == Some(&b'$') || (i > 0 && bytes[i - 1] == b'$');
146 if bytes[i] == b'$' && prefix_ok && !part_of_double {
147 if let Some(rel) = memchr::memchr(b'$', &content.as_bytes()[i + 1..]) {
148 let equation = &content[i + 1..i + 1 + rel];
149 if !equation.is_empty() && !equation.contains('\n') {
150 out.push_str(&content[span_start..i]);
151 out.push_str("$$");
152 out.push_str(equation);
153 out.push('$');
154 i += 1 + rel + 1;
155 span_start = i;
156 continue;
157 }
158 }
159 }
160 i += 1;
161 }
162 out.push_str(&content[span_start..]);
163 out
164}
165
166pub fn preprocess(content: &str, options: &PreprocessOptions) -> String {
168 let mut result = content.to_string();
169 result = fix_code(&result);
170 result = fix_html(&result);
171 result = fix_footnote(&result);
172 result = fix_strong(&result, options);
173 result = fix_emphasis(&result);
174 result = fix_delete(&result);
175 result = fix_task_list(&result);
176 result = fix_link(&result);
177 result = fix_table(&result);
178 result = fix_inline_math(&result);
179 result = fix_math(&result);
180 result
181}
182
183pub fn normalize(content: &str) -> String {
184 let cleaned = proprocess_content(content);
185 preprocess_latex(&cleaned)
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn normalize_crlf() {
194 assert_eq!(normalize("a\r\nb\r\n\r\n"), "a\nb");
195 assert_eq!(normalize(" text \n\n"), " text");
196 }
197
198 #[test]
199 fn latex_preprocessing() {
200 assert_eq!(preprocess_latex(r"\(x^2\)"), "$$x^2$$");
203 assert_eq!(preprocess_latex(r"\[x^2\]"), "$$x^2$$");
204 assert_eq!(preprocess_latex(r"$x$ and $y$"), "$$x$ and $$y$");
205 assert_eq!(preprocess_latex("```\n$100\n```"), "```\n$100\n```");
206 assert_eq!(preprocess_latex("$$x$$"), "$$x$$");
208 assert_eq!(
209 preprocess_latex("The formula is $$x = 1$$"),
210 "The formula is $$x = 1$$"
211 );
212 assert_eq!(preprocess_latex("$$\nE = mc^2\n$$"), "$$\nE = mc^2\n$$");
213 }
214
215 #[test]
216 fn pipeline_order() {
217 let opts = PreprocessOptions::default();
219 let r = preprocess("- [", &opts);
220 assert_eq!(r, "");
221 }
222}