Skip to main content

rsmarkdown_core/
preprocess.rs

1//! `normalize` + `preprocess` pipeline, ported from
2//! `markmend/core/src/preprocess/index.ts` and `vendored/markdown-utils.ts`.
3
4use crate::fix::*;
5
6#[derive(Debug, Clone, Copy, Default)]
7pub struct PreprocessOptions {
8    /// Treat single `$...$` as inline math (default: false, only `$$` counts).
9    pub single_dollar_text_math: bool,
10}
11
12/// CRLF -> LF, trim trailing whitespace.
13fn 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
25/// `/\\\[(.*?)\\\]/` -> `$$...$$` (single-line bracket math)
26/// `/\\\[([\s\S]*?)\\\]/` -> `$$...$$` (block bracket math)
27/// `/\\\((.*?)\\\)/` -> `$$...$$` (paren math)
28/// `/(^|[^\\])\$(.+?)\$/` -> `$1$$$2$` (dollar math)
29pub fn preprocess_latex(content: &str) -> String {
30    // fast path: nothing to rewrite when no LaTeX markers or dollars are present
31    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    // restore code blocks in one pass (escaped `$` kept until the final unescape)
69    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    // find `\[` ... `\]` spans, convert content to `$$content$$`
85    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
133/// `/(^|[^\\])\$(.+?)\$/g` -> `$1$$$2$`
134///
135/// Improvement over the original: a `$` that is part of an existing `$$` pair is
136/// left untouched, so single-line `$$x$$` survives normalize intact (the original
137/// mangles it into `$$$x$$` and breaks single-line math).
138fn 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
166/// Ordered preprocess steps — order matters (ported from `DEFAULT_PREPROCESS_STEP_NAMES`).
167pub 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        // `\(x\)` / `\[x\]` convert to `$$...$$` and — unlike the original —
201        // are NOT re-mangled by the dollar rewrite
202        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        // single-line `$$...$$` is preserved (original mangles it to `$$$...$$`)
207        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        // fixTaskList must run before fixLink: `-[` is a task list start, not a link
218        let opts = PreprocessOptions::default();
219        let r = preprocess("- [", &opts);
220        assert_eq!(r, "");
221    }
222}