Skip to main content

katex/
stretchy.rs

1//! Stretchy wide elements rendered from SVG files and CSS tricks
2//!
3//! This module provides support for building stretchy wide elements that are
4//! rendered using SVG files and CSS overflow techniques. It includes functions
5//! for creating SVG spans, enclosing spans, and MathML nodes for stretchy
6//! symbols.
7
8use alloc::borrow::Cow;
9
10use crate::ParseError;
11use crate::build_common::{make_span, make_svg_span};
12use crate::dom_tree::{DomSpan, HtmlDomNode, LineNode, PathNode, SvgChildNode, SvgNode};
13use crate::mathml_tree::{MathNode, MathNodeType, TextNode};
14use crate::namespace::KeyMap;
15use crate::options::Options;
16use crate::parser::parse_node::AnyParseNode;
17use crate::types::ClassList;
18use crate::types::CssProperty;
19use crate::types::ParseErrorKind;
20use crate::units::make_em;
21use phf::{phf_map, phf_set};
22
23/// Code point mapping for stretchy symbols
24pub const STRETCHY_CODE_POINT: phf::Map<&'static str, &'static str> = phf_map! {
25    "widehat" => "^",
26    "widecheck" => "\u{2c7}",
27    "widetilde" => "~",
28    "utilde" => "~",
29    "overleftarrow" => "\u{2190}",
30    "underleftarrow" => "\u{2190}",
31    "xleftarrow" => "\u{2190}",
32    "overrightarrow" => "\u{2192}",
33    "underrightarrow" => "\u{2192}",
34    "xrightarrow" => "\u{2192}",
35    "underbrace" => "\u{23df}",
36    "overbrace" => "\u{23de}",
37    "overgroup" => "\u{23e0}",
38    "undergroup" => "\u{23e1}",
39    "overleftrightarrow" => "\u{2194}",
40    "underleftrightarrow" => "\u{2194}",
41    "xleftrightarrow" => "\u{2194}",
42    "Overrightarrow" => "\u{21d2}",
43    "xRightarrow" => "\u{21d2}",
44    "overleftharpoon" => "\u{21bc}",
45    "xleftharpoonup" => "\u{21bc}",
46    "overrightharpoon" => "\u{21c0}",
47    "xrightharpoonup" => "\u{21c0}",
48    "xLeftarrow" => "\u{21d0}",
49    "xLeftrightarrow" => "\u{21d4}",
50    "xhookleftarrow" => "\u{21a9}",
51    "xhookrightarrow" => "\u{21aa}",
52    "xmapsto" => "\u{21a6}",
53    "xrightharpoondown" => "\u{21c1}",
54    "xleftharpoondown" => "\u{21bd}",
55    "xrightleftharpoons" => "\u{21cc}",
56    "xleftrightharpoons" => "\u{21cb}",
57    "xtwoheadleftarrow" => "\u{219e}",
58    "xtwoheadrightarrow" => "\u{21a0}",
59    "xlongequal" => "=",
60    "xtofrom" => "\u{21c4}",
61    "xrightleftarrows" => "\u{21c4}",
62    "xrightequilibrium" => "\u{21cc}",
63    "xleftequilibrium" => "\u{21cb}",
64    "\\cdrightarrow" => "\u{2192}",
65    "\\cdleftarrow" => "\u{2190}",
66    "\\cdlongequal" => "=",
67};
68
69/// Data structure for image information
70#[derive(Debug, Clone)]
71pub struct ImageData {
72    /// SVG path names or path arrays
73    pub paths: &'static [&'static str],
74    /// Minimum width in em units
75    pub min_width: f64,
76    /// Height in em units
77    pub height: f64,
78    /// Optional alignment string
79    pub align: Option<&'static str>,
80}
81
82impl ImageData {
83    /// Create a new [`ImageData`] instance
84    #[must_use]
85    pub const fn new(
86        paths: &'static [&'static str],
87        min_width: f64,
88        height: f64,
89        align: Option<&'static str>,
90    ) -> Self {
91        Self {
92            paths,
93            min_width,
94            height,
95            align,
96        }
97    }
98}
99
100const IMAGES_DATA: phf::Map<&'static str, ImageData> = phf_map! {
101    "overrightarrow" => ImageData::new(&["rightarrow"], 0.888, 522.0, Some("xMaxYMin")),
102    "overleftarrow" => ImageData::new(&["leftarrow"], 0.888, 522.0, Some("xMinYMin")),
103    "underrightarrow" => ImageData::new(&["rightarrow"], 0.888, 522.0, Some("xMaxYMin")),
104    "underleftarrow" => ImageData::new(&["leftarrow"], 0.888, 522.0, Some("xMinYMin")),
105    "xrightarrow" => ImageData::new(&["rightarrow"], 1.469, 522.0, Some("xMaxYMin")),
106    "\\cdrightarrow" => ImageData::new(&["rightarrow"], 3.0, 522.0, Some("xMaxYMin")),
107    "xleftarrow" => ImageData::new(&["leftarrow"], 1.469, 522.0, Some("xMinYMin")),
108    "\\cdleftarrow" => ImageData::new(&["leftarrow"], 3.0, 522.0, Some("xMinYMin")),
109    "Overrightarrow" => ImageData::new(&["doublerightarrow"], 0.888, 560.0, Some("xMaxYMin")),
110    "xRightarrow" => ImageData::new(&["doublerightarrow"], 1.526, 560.0, Some("xMaxYMin")),
111    "xLeftarrow" => ImageData::new(&["doubleleftarrow"], 1.526, 560.0, Some("xMinYMin")),
112    "overleftharpoon" => ImageData::new(&["leftharpoon"], 0.888, 522.0, Some("xMinYMin")),
113    "xleftharpoonup" => ImageData::new(&["leftharpoon"], 0.888, 522.0, Some("xMinYMin")),
114    "xleftharpoondown" => ImageData::new(&["leftharpoondown"], 0.888, 522.0, Some("xMinYMin")),
115    "overrightharpoon" => ImageData::new(&["rightharpoon"], 0.888, 522.0, Some("xMaxYMin")),
116    "xrightharpoonup" => ImageData::new(&["rightharpoon"], 0.888, 522.0, Some("xMaxYMin")),
117    "xrightharpoondown" => ImageData::new(&["rightharpoondown"], 0.888, 522.0, Some("xMaxYMin")),
118    "xlongequal" => ImageData::new(&["longequal"], 0.888, 334.0, Some("xMinYMin")),
119    "\\cdlongequal" => ImageData::new(&["longequal"], 3.0, 334.0, Some("xMinYMin")),
120    "xtwoheadleftarrow" => ImageData::new(&["twoheadleftarrow"], 0.888, 334.0, Some("xMinYMin")),
121    "xtwoheadrightarrow" => ImageData::new(&["twoheadrightarrow"], 0.888, 334.0, Some("xMaxYMin")),
122    "overleftrightarrow" => ImageData::new(&["leftarrow", "rightarrow"], 0.888, 522.0, None),
123    "overbrace" => ImageData::new(&["leftbrace", "midbrace", "rightbrace"], 1.6, 548.0, None),
124    "underbrace" => ImageData::new(&["leftbraceunder", "midbraceunder", "rightbraceunder"], 1.6, 548.0, None),
125    "underleftrightarrow" => ImageData::new(&["leftarrow", "rightarrow"], 0.888, 522.0, None),
126    "xleftrightarrow" => ImageData::new(&["leftarrow", "rightarrow"], 1.75, 522.0, None),
127    "xLeftrightarrow" => ImageData::new(&["doubleleftarrow", "doublerightarrow"], 1.75, 560.0, None),
128    "xrightleftharpoons" => ImageData::new(&["leftharpoondownplus", "rightharpoonplus"], 1.75, 716.0, None),
129    "xleftrightharpoons" => ImageData::new(&["leftharpoonplus", "rightharpoondownplus"], 1.75, 716.0, None),
130    "xhookleftarrow" => ImageData::new(&["leftarrow", "righthook"], 1.08, 522.0, None),
131    "xhookrightarrow" => ImageData::new(&["lefthook", "rightarrow"], 1.08, 522.0, None),
132    "overlinesegment" => ImageData::new(&["leftlinesegment", "rightlinesegment"], 0.888, 522.0, None),
133    "underlinesegment" => ImageData::new(&["leftlinesegment", "rightlinesegment"], 0.888, 522.0, None),
134    "overgroup" => ImageData::new(&["leftgroup", "rightgroup"], 0.888, 342.0, None),
135    "undergroup" => ImageData::new(&["leftgroupunder", "rightgroupunder"], 0.888, 342.0, None),
136    "xmapsto" => ImageData::new(&["leftmapsto", "rightarrow"], 1.5, 522.0, None),
137    "xtofrom" => ImageData::new(&["leftToFrom", "rightToFrom"], 1.75, 528.0, None),
138    "xrightleftarrows" => ImageData::new(&["baraboveleftarrow", "rightarrowabovebar"], 1.75, 901.0, None),
139    "xrightequilibrium" => ImageData::new(&["baraboveshortleftharpoon", "rightharpoonaboveshortbar"], 1.75, 716.0, None),
140    "xleftequilibrium" => ImageData::new(&["shortbaraboveleftharpoon", "shortrightharpoonabovebar"], 1.75, 716.0, None),
141};
142
143/// Calculate the length of an ordgroup parse node
144const fn group_length(arg: &AnyParseNode) -> usize {
145    if let AnyParseNode::OrdGroup(ordgroup) = arg {
146        ordgroup.body.len()
147    } else {
148        1
149    }
150}
151
152const ACCENT_STRETCHY: phf::Set<&'static str> = phf_set! {
153    "widehat", "widecheck", "widetilde", "utilde"
154};
155
156const ACCENT_STRETCHY_OVER: phf::Set<&'static str> = phf_set! {
157    "widehat", "widecheck"
158};
159
160/// Create an SVG span for stretchy elements
161pub fn svg_span(group: &AnyParseNode, options: &Options) -> Result<HtmlDomNode, ParseError> {
162    // Extract the label from the group
163    let Some(label) = group.label() else {
164        return Err(ParseError::new(
165            ParseErrorKind::UnsupportedGroupTypeForSvgSpan,
166        ));
167    };
168
169    let Some(label) = label.strip_prefix('\\') else {
170        return Err(ParseError::new(ParseErrorKind::LabelMissingBackslashPrefix));
171    };
172
173    if ACCENT_STRETCHY.contains(label) {
174        // Handle accent-style stretchy elements
175        let grp_base = match group {
176            AnyParseNode::Accent(acc) => &acc.base,
177            AnyParseNode::AccentUnder(acc_under) => &acc_under.base,
178            _ => {
179                return Err(ParseError::new(ParseErrorKind::InvalidGroupTypeForAccent));
180            }
181        };
182
183        let num_chars = group_length(grp_base) as f64;
184        let (view_box_width, view_box_height, height_val, path_name) = if num_chars > 5.0 {
185            if ACCENT_STRETCHY_OVER.contains(label) {
186                (2364.0, 420.0, 0.42, format!("{label}4"))
187            } else {
188                (2340.0, 312.0, 0.34, "tilde4".to_owned())
189            }
190        } else {
191            let img_index = [1, 1, 2, 2, 3, 3][num_chars as usize];
192            if ACCENT_STRETCHY_OVER.contains(label) {
193                let widths = [0.0, 1062.0, 2364.0, 2364.0, 2364.0];
194                let heights = [0.0, 239.0, 300.0, 360.0, 420.0];
195                let h_vals = [0.0, 0.24, 0.3, 0.3, 0.36, 0.42];
196                (
197                    widths[img_index],
198                    heights[img_index],
199                    h_vals[img_index],
200                    format!("{label}{img_index}"),
201                )
202            } else {
203                let widths = [0.0, 600.0, 1033.0, 2339.0, 2340.0];
204                let heights = [0.0, 260.0, 286.0, 306.0, 312.0];
205                let h_vals = [0.0, 0.26, 0.286, 0.3, 0.306, 0.34];
206                (
207                    widths[img_index],
208                    heights[img_index],
209                    h_vals[img_index],
210                    format!("tilde{img_index}"),
211                )
212            }
213        };
214
215        let path = PathNode {
216            path_name,
217            alternate: None,
218        };
219
220        let mut svg_node = SvgNode::builder()
221            .children(vec![SvgChildNode::Path(path)])
222            .build();
223        svg_node.attributes.extend([
224            ("width".to_owned(), "100%".to_owned()),
225            ("height".to_owned(), make_em(height_val)),
226            (
227                "viewBox".to_owned(),
228                format!("0 0 {view_box_width} {view_box_height}"),
229            ),
230            ("preserveAspectRatio".to_owned(), "none".to_owned()),
231        ]);
232        let mut span = make_svg_span(vec![], vec![svg_node], options);
233
234        span.height = height_val;
235        span.style.insert(CssProperty::Height, make_em(height_val));
236
237        Ok(span.into())
238    } else {
239        // Handle other stretchy elements
240        let data = IMAGES_DATA.get(label).ok_or_else(|| {
241            ParseError::new(ParseErrorKind::UnknownStretchyElement {
242                label: label.to_owned(),
243            })
244        })?;
245
246        let mut spans: Vec<HtmlDomNode> = Vec::new();
247        let height_val = data.height / 1000.0;
248        let view_box_width = 400000.0;
249
250        let (width_classes, aligns): (&[&str], &[&str]) = match data.paths.len() {
251            1 => {
252                let align = data.align.unwrap_or("xMinYMin");
253                (&["hide-tail"], &[align])
254            }
255            2 => (
256                &["halfarrow-left", "halfarrow-right"],
257                &["xMinYMin", "xMaxYMin"],
258            ),
259            3 => (
260                &["brace-left", "brace-center", "brace-right"],
261                &["xMinYMin", "xMidYMin", "xMaxYMin"],
262            ),
263            _ => {
264                return Err(ParseError::new(
265                    ParseErrorKind::UnsupportedStretchyPathCount {
266                        count: data.paths.len(),
267                    },
268                ));
269            }
270        };
271
272        for (i, (width_class, align)) in width_classes.iter().zip(aligns.iter()).enumerate() {
273            let path = PathNode {
274                path_name: data.paths[i].to_owned(),
275                alternate: None,
276            };
277
278            let mut svg_node = SvgNode::builder()
279                .children(vec![SvgChildNode::Path(path)])
280                .build();
281
282            svg_node.attributes.extend([
283                ("width".to_owned(), "400em".to_owned()),
284                ("height".to_owned(), make_em(height_val)),
285                (
286                    "viewBox".to_owned(),
287                    format!("0 0 {} {}", view_box_width, data.height),
288                ),
289                ("preserveAspectRatio".to_owned(), format!("{align} slice")),
290            ]);
291
292            let span = make_span(
293                ClassList::Static(width_class),
294                vec![HtmlDomNode::SvgNode(svg_node)],
295                Some(options),
296                None,
297            );
298
299            let mut span = span;
300            if data.paths.len() == 1 {
301                // For single path, return directly
302                span.height = height_val;
303                span.style.insert(CssProperty::Height, make_em(height_val));
304                if data.min_width > 0.0 {
305                    span.style
306                        .insert(CssProperty::MinWidth, make_em(data.min_width));
307                }
308                return Ok(span.into());
309            }
310
311            // For multiple paths, collect spans
312            span.height = height_val;
313            span.style.insert(CssProperty::Height, make_em(height_val));
314            spans.push(span.into());
315        }
316
317        // For multiple paths, create a stretchy span containing all spans
318        let mut span = make_span("stretchy", spans, Some(options), None);
319        span.height = height_val;
320        span.style.insert(CssProperty::Height, make_em(height_val));
321        if data.min_width > 0.0 {
322            span.style
323                .insert(CssProperty::MinWidth, make_em(data.min_width));
324        }
325
326        Ok(span.into())
327    }
328}
329
330/// Create an enclosing span for elements like cancel, fbox, etc.
331pub fn enclose_span(
332    inner: &HtmlDomNode,
333    label: &str,
334    top_pad: f64,
335    bottom_pad: f64,
336    options: &Options,
337) -> DomSpan {
338    let total_height = inner.height() + inner.depth() + top_pad + bottom_pad;
339
340    let is_box_like = label.contains("fbox") || label.contains("color");
341    if is_box_like || label == "angl" {
342        let classes = vec![Cow::Borrowed("stretchy"), Cow::Owned(label.to_owned())];
343        let mut span = make_span(classes, vec![], Some(options), None);
344
345        if label == "fbox"
346            && let Some(color) = options.get_color()
347        {
348            span.style.insert(CssProperty::BorderColor, color);
349        }
350
351        span.style
352            .insert(CssProperty::Height, make_em(total_height));
353        span.height = total_height;
354        span
355    } else {
356        // Handle cancel, bcancel, xcancel
357        let mut lines = Vec::new();
358
359        if label == "bcancel" || label == "xcancel" {
360            lines.push(LineNode {
361                attributes: [
362                    ("x1".to_owned(), "0".to_owned()),
363                    ("y1".to_owned(), "0".to_owned()),
364                    ("x2".to_owned(), "100%".to_owned()),
365                    ("y2".to_owned(), "100%".to_owned()),
366                    ("stroke-width".to_owned(), "0.046em".to_owned()),
367                ]
368                .iter()
369                .cloned()
370                .collect(),
371            });
372        }
373
374        if label == "cancel" || label == "xcancel" {
375            lines.push(LineNode {
376                attributes: [
377                    ("x1".to_owned(), "0".to_owned()),
378                    ("y1".to_owned(), "100%".to_owned()),
379                    ("x2".to_owned(), "100%".to_owned()),
380                    ("y2".to_owned(), "0".to_owned()),
381                    ("stroke-width".to_owned(), "0.046em".to_owned()),
382                ]
383                .iter()
384                .cloned()
385                .collect(),
386            });
387        }
388
389        let svg_attributes = [
390            ("width".to_owned(), "100%".to_owned()),
391            ("height".to_owned(), make_em(total_height)),
392        ]
393        .iter()
394        .cloned()
395        .collect();
396
397        let svg_node = SvgNode::builder()
398            .children(lines.into_iter().map(SvgChildNode::Line).collect())
399            .attributes(svg_attributes)
400            .build();
401
402        let mut span = make_svg_span(vec![], vec![svg_node], options);
403        span.style
404            .insert(CssProperty::Height, make_em(total_height));
405        span.height = total_height;
406        span
407    }
408}
409
410/// Create a MathML node for stretchy elements
411#[must_use]
412pub fn math_ml_node(label: &str) -> MathNode {
413    let code_point = STRETCHY_CODE_POINT
414        .get(label.trim_start_matches('\\'))
415        .unwrap_or(&" ");
416
417    let text_node = TextNode {
418        text: (*code_point).to_owned(),
419    };
420
421    let mut node = MathNode {
422        node_type: MathNodeType::Mo,
423        attributes: KeyMap::default(),
424        children: vec![text_node.into()],
425        classes: ClassList::Empty,
426    };
427
428    node.attributes
429        .insert("stretchy".to_owned(), "true".to_owned());
430    node
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::dom_tree::Span;
437    use crate::parser::parse_node::ParseNodeMathOrd;
438    use crate::types::Mode;
439
440    #[test]
441    fn test_stretchy_code_point() {
442        assert_eq!(STRETCHY_CODE_POINT.get("widehat"), Some(&"^"));
443        assert_eq!(STRETCHY_CODE_POINT.get("overleftarrow"), Some(&"\u{2190}"));
444        assert_eq!(STRETCHY_CODE_POINT.get("nonexistent"), None);
445    }
446
447    #[test]
448    fn test_get_katex_images_data() {
449        let data = IMAGES_DATA;
450        assert!(data.contains_key("overrightarrow"));
451        assert!(data.contains_key("overbrace"));
452
453        let overrightarrow = data.get("overrightarrow").unwrap();
454        assert_eq!(overrightarrow.min_width, 0.888f64);
455        assert_eq!(overrightarrow.height, 522f64);
456        assert_eq!(overrightarrow.align, Some("xMaxYMin"));
457    }
458
459    #[test]
460    fn test_group_length() {
461        // Test with a simple non-ordgroup
462        let simple_node = AnyParseNode::MathOrd(ParseNodeMathOrd {
463            mode: Mode::Math,
464            loc: None,
465            text: "x".into(),
466        });
467        assert_eq!(group_length(&simple_node), 1);
468    }
469
470    #[test]
471    fn test_svg_span_basic_functionality() {
472        use crate::options::Options;
473        use crate::style;
474
475        let options = Options::builder()
476            .style(style::TEXT)
477            .phantom(false)
478            .max_size(1_000_000.0)
479            .min_rule_thickness(0.04)
480            .build();
481
482        // Test that the function exists and can be called
483        // We'll use a simple test that should work with our current implementation
484        let simple_node = AnyParseNode::MathOrd(ParseNodeMathOrd {
485            mode: Mode::Math,
486            loc: None,
487            text: "x".into(),
488        });
489
490        // This should not panic and should return an error for unsupported node type
491        let result = svg_span(&simple_node, &options);
492        assert!(result.is_err());
493    }
494
495    #[test]
496    fn test_math_ml_node() {
497        let node = math_ml_node("widehat");
498        assert_eq!(node.node_type, MathNodeType::Mo);
499        assert_eq!(node.attributes.get("stretchy"), Some(&"true".to_owned()));
500        assert_eq!(node.children.len(), 1);
501    }
502
503    #[test]
504    fn test_enclose_span() {
505        use crate::options::Options;
506        use crate::style;
507
508        let options = Options::builder()
509            .style(style::TEXT)
510            .phantom(false)
511            .max_size(1_000_000.0)
512            .min_rule_thickness(0.04)
513            .build();
514
515        let inner: Span<HtmlDomNode> = Span::builder()
516            .children(vec![])
517            .height(1.0)
518            .depth(0.5)
519            .build(None);
520
521        let result = enclose_span(&HtmlDomNode::DomSpan(inner), "cancel", 0.1, 0.1, &options);
522        assert!(result.height > 0.0);
523        assert!(result.style.contains_key(CssProperty::Height));
524    }
525}