Skip to main content

textlane_html/actions/hast/
math.rs

1use textlane_core::hast::{Element, Node, Property, PropertyItem, PropertyValue};
2use textlane_core::{Allocator, ArenaVec};
3
4use crate::helpers::prose::{create_text, replace_prose};
5use crate::models::options::Math;
6
7/// Turn `$…$` and `$$…$$` in text into the `<code class="language-math math-inline">` and
8/// `math-display` elements `md.math` makes, for `html.katex` to render.
9pub fn mark_math<'a>(node: &mut Node<'a>, options: &Math, alloc: &'a Allocator) {
10    replace_prose(
11        node,
12        &options.ignore,
13        &options.ignore_attributes,
14        alloc,
15        |text, out| {
16            if !text.contains('$') {
17                return false;
18            }
19            let segments = split_math(text, options.single_dollar_text_math);
20            if !segments
21                .iter()
22                .any(|segment| !matches!(segment, Segment::Text(_)))
23            {
24                return false;
25            }
26            let mut plain = String::new();
27            for segment in segments {
28                match segment {
29                    Segment::Text(text) => plain.push_str(&text),
30                    Segment::Dollar => plain.push('$'),
31                    Segment::Math(latex, display) => {
32                        if !plain.is_empty() {
33                            out.push(create_text(&std::mem::take(&mut plain), alloc));
34                        }
35                        out.push(create_math(&latex, display, alloc));
36                    }
37                }
38            }
39            if !plain.is_empty() {
40                out.push(create_text(&plain, alloc));
41            }
42            true
43        },
44    );
45}
46
47enum Segment {
48    Text(String),
49    Math(String, bool),
50    Dollar,
51}
52
53fn split_math(text: &str, single: bool) -> Vec<Segment> {
54    let mut segments = vec![];
55    let mut plain = String::new();
56    let mut rest = text;
57    while let Some(at) = rest.find('$') {
58        plain.push_str(&rest[..at]);
59        let after = &rest[at..];
60        if plain.ends_with('\\') {
61            plain.pop();
62            segments.push(Segment::Text(std::mem::take(&mut plain)));
63            segments.push(Segment::Dollar);
64            rest = &after[1..];
65            continue;
66        }
67        let found = match after.strip_prefix("$$") {
68            Some(inner) => inner.find("$$").and_then(|end| {
69                let latex = inner[..end].trim();
70                (!latex.is_empty()).then(|| (latex.to_owned(), true, end + 4))
71            }),
72            None if single => find_inline(&after[1..])
73                .map(|(latex, end)| (latex.to_owned(), false, end + 1)),
74            None => None,
75        };
76        match found {
77            Some((latex, display, length)) => {
78                segments.push(Segment::Text(std::mem::take(&mut plain)));
79                segments.push(Segment::Math(latex, display));
80                rest = &after[length..];
81            }
82            None => {
83                plain.push('$');
84                rest = &after[1..];
85            }
86        }
87    }
88    plain.push_str(rest);
89    segments.push(Segment::Text(plain));
90    segments
91}
92
93// The LaTeX of an inline `$…$` starting right after its opening `$`, and how far its
94// closing `$` reaches
95fn find_inline(text: &str) -> Option<(&str, usize)> {
96    if text.is_empty() || text.starts_with(char::is_whitespace) {
97        return None;
98    }
99    let mut search = 0;
100    while let Some(offset) = text[search..].find('$') {
101        let end = search + offset;
102        let before = text[..end].chars().next_back();
103        let after = text[end + 1..].chars().next();
104        let closes = before.is_some_and(|char| !char.is_whitespace() && char != '\\')
105            && !after.is_some_and(|char| char.is_ascii_digit());
106        if closes && end > 0 {
107            return Some((&text[..end], end + 1));
108        }
109        search = end + 1;
110    }
111    None
112}
113
114fn create_math<'a>(latex: &str, display: bool, alloc: &'a Allocator) -> Node<'a> {
115    let mut classes = ArenaVec::with_capacity_in(2, alloc);
116    classes.push(PropertyItem::String("language-math"));
117    classes.push(PropertyItem::String(match display {
118        true => "math-display",
119        false => "math-inline",
120    }));
121    let mut properties = ArenaVec::with_capacity_in(1, alloc);
122    properties.push(Property {
123        name: "className",
124        value: PropertyValue::List(classes),
125    });
126    let mut children = ArenaVec::with_capacity_in(1, alloc);
127    children.push(create_text(latex, alloc));
128    Node::Element(Element {
129        tag_name: "code",
130        properties,
131        children,
132        position: None,
133        data: None,
134        content: None,
135    })
136}
137
138#[cfg(test)]
139#[path = "math_unit.rs"]
140mod unit;