Skip to main content

leaf_math/
lib.rs

1//! TeX math, typeset to a picture leaf's frontends already know how to draw.
2//!
3//! One function: [`typeset`] takes the text between a formula's delimiters and
4//! hands back a standalone SVG document plus the three numbers a frontend
5//! needs to place it — `width`, `height` above the baseline, and `depth`
6//! below it, in em. A display formula needs only the first two to size its
7//! box; an inline one needs all three, because it sits *in* a line and the
8//! text baseline has to pass through it at `height` from the top.
9//!
10//! The SVG is self-contained: every glyph is a `<path>` with KaTeX's own
11//! outlines embedded at build time, so the picture is byte-identical on every
12//! frontend and no font has to be found at runtime. Colour is an input, so a
13//! theme change is a re-render; the render is pure layout over embedded fonts
14//! — no I/O, and fast enough to run on a layout thread. Callers cache by
15//! `(tex, display, size, colour)`.
16//!
17//! Nothing here is leaf's: it is RaTeX with leaf's conventions on the door
18//! (pixels rather than points, a colour as four bytes, an error that names a
19//! byte). If it grows a consumer outside leaf it moves out, the way
20//! resvg-swift did.
21
22use ratex_layout::{LayoutOptions, layout, to_display_list};
23use ratex_parser::parse;
24use ratex_svg::{SvgColorSyntax, SvgOptions, render_to_svg_with_color_syntax};
25use ratex_types::color::Color;
26use ratex_types::math_style::MathStyle;
27use std::fmt;
28
29/// A typeset formula: the picture and where its baseline is.
30#[derive(Clone, Debug, PartialEq)]
31pub struct MathPicture {
32    /// A standalone SVG document. Its `viewBox`, `width`, and `height` are in
33    /// pixels at the `size` the formula was typeset at — `width * size` by
34    /// `(height + depth) * size` — so a frontend that draws it at its intrinsic
35    /// size lands the glyphs at the font size it asked for.
36    pub svg: String,
37    /// The advance width of the formula, in em.
38    pub width: f64,
39    /// How far the formula rises above its baseline, in em. The baseline of
40    /// the surrounding text should pass through the picture this far from its
41    /// top.
42    pub height: f64,
43    /// How far the formula reaches below its baseline, in em.
44    pub depth: f64,
45}
46
47impl MathPicture {
48    /// The picture's pixel width at the `size` it was typeset at.
49    pub fn px_width(&self, size: f64) -> f64 {
50        self.width * size
51    }
52
53    /// The picture's pixel height at the `size` it was typeset at — the
54    /// ascent plus the descent.
55    pub fn px_height(&self, size: f64) -> f64 {
56        (self.height + self.depth) * size
57    }
58}
59
60/// TeX the typesetter could not read. `position` is a byte offset into the
61/// formula's text where the parser gave up, when it can say — so a frontend
62/// can show the revealed source with the fault marked, rather than a blank
63/// box.
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct MathError {
66    pub message: String,
67    pub position: Option<usize>,
68}
69
70impl fmt::Display for MathError {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self.position {
73            Some(at) => write!(f, "{} (at byte {at})", self.message),
74            None => f.write_str(&self.message),
75        }
76    }
77}
78
79impl std::error::Error for MathError {}
80
81/// Typeset `tex` — the text between the delimiters, `$…$` and `$$…$$` alike
82/// stripped — as a picture.
83///
84/// `display` chooses TeX's display style (limits above and below a `\sum`,
85/// a full-height fraction) over text style, the same switch `$$` makes over
86/// `$`. `size` is the font size in pixels the formula is set at: an inline
87/// formula takes the size of the text around it, a display one the body size.
88/// `color` is the ink, as `[r, g, b, a]` bytes.
89///
90/// Whitespace either side of `tex` is insignificant to TeX and is trimmed, so
91/// a display block whose source keeps the formula on lines of its own reads
92/// the same as one written on the delimiters' line.
93pub fn typeset(
94    tex: &str,
95    display: bool,
96    size: f64,
97    color: [u8; 4],
98) -> Result<MathPicture, MathError> {
99    let ast = parse(tex.trim()).map_err(|e| MathError {
100        message: e.message,
101        position: e.loc.map(|l| l.start),
102    })?;
103    let style = if display {
104        MathStyle::Display
105    } else {
106        MathStyle::Text
107    };
108    let [r, g, b, a] = color.map(|c| f32::from(c) / 255.0);
109    let opts = LayoutOptions::default()
110        .with_style(style)
111        .with_color(Color::new(r, g, b, a));
112    let list = to_display_list(&layout(&ast, &opts));
113    let svg = render_to_svg_with_color_syntax(
114        &list,
115        &SvgOptions {
116            font_size: size,
117            padding: 0.0,
118            stroke_width: (size / 16.0).max(0.5),
119            embed_glyphs: true,
120            font_dir: String::new(),
121        },
122        // `rgb()` plus an opacity attribute, which every SVG consumer leaf
123        // has (resvg, Core Graphics through resvg-swift, the browser) reads;
124        // `rgba()` in a fill is CSS Color 4 and not SVG 1.1.
125        SvgColorSyntax::Rgb,
126    );
127    Ok(MathPicture {
128        svg: in_pixels(svg),
129        width: list.width,
130        height: list.height,
131        depth: list.depth,
132    })
133}
134
135/// RaTeX writes the root's `width` and `height` in `pt`, so that a 40-unit em
136/// prints at 40pt. leaf's frontends size a picture in pixels — an `<img>`, a
137/// `CGSize`, a gpui `Pixels` — and a `pt` unit would have every one of them
138/// scale it by 4/3 on the way in. Strip the unit: the viewBox is already the
139/// pixel box, and an unqualified length is a user unit, which is a pixel.
140fn in_pixels(svg: String) -> String {
141    let Some(head_end) = svg.find('>') else {
142        return svg;
143    };
144    let (head, rest) = svg.split_at(head_end);
145    let mut head = head.to_string();
146    for attr in ["width=\"", "height=\""] {
147        if let Some(i) = head.find(attr) {
148            let v = i + attr.len();
149            if let Some(q) = head[v..].find('"') {
150                let value = head[v..v + q].trim_end_matches("pt").to_string();
151                head.replace_range(v..v + q, &value);
152            }
153        }
154    }
155    head.push_str(rest);
156    head
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    const BLACK: [u8; 4] = [0, 0, 0, 255];
164
165    #[test]
166    fn an_inline_formula_is_a_standalone_svg_with_metrics() {
167        let p = typeset("E = mc^2", false, 16.0, BLACK).unwrap();
168        assert!(
169            p.svg
170                .starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\"")
171        );
172        assert!(p.svg.ends_with("</svg>"));
173        // Glyphs are outlines, not font references.
174        assert!(p.svg.contains("<path"), "{}", p.svg);
175        assert!(!p.svg.contains("<text"), "{}", p.svg);
176        assert!(!p.svg.contains("font-family"), "{}", p.svg);
177        // `E = mc^2` is a few em wide, sits on its baseline, and has no descender.
178        assert!(p.width > 3.0 && p.width < 5.0, "{}", p.width);
179        assert!(p.height > 0.6 && p.height < 1.0, "{}", p.height);
180        assert_eq!(p.depth, 0.0);
181    }
182
183    #[test]
184    fn a_display_formula_has_a_depth_and_a_display_style() {
185        let d = typeset(r"\sum_{i=0}^n i", true, 16.0, BLACK).unwrap();
186        let t = typeset(r"\sum_{i=0}^n i", false, 16.0, BLACK).unwrap();
187        // Display style sets the limits above and below the sum: taller and
188        // narrower than text style, which sets them as sub/superscripts.
189        assert!(d.height + d.depth > t.height + t.depth, "{d:?} vs {t:?}");
190        assert!(d.width < t.width, "{d:?} vs {t:?}");
191        assert!(d.depth > 0.0);
192    }
193
194    #[test]
195    fn the_root_is_sized_in_pixels_at_the_requested_size() {
196        let p = typeset("x", false, 20.0, BLACK).unwrap();
197        // No `pt` anywhere in the root element.
198        let head = &p.svg[..p.svg.find('>').unwrap()];
199        assert!(!head.contains("pt"), "{head}");
200        // And the width attribute is the em width scaled by the size.
201        let attr = |name: &str| -> f64 {
202            head.split(&format!("{name}=\""))
203                .nth(1)
204                .unwrap()
205                .split('"')
206                .next()
207                .unwrap()
208                .parse()
209                .unwrap()
210        };
211        assert!((attr("width") - p.px_width(20.0)).abs() < 1e-3);
212        assert!((attr("height") - p.px_height(20.0)).abs() < 1e-3);
213    }
214
215    #[test]
216    fn colour_is_the_ink() {
217        let p = typeset("x", false, 16.0, [255, 0, 0, 255]).unwrap();
218        assert!(p.svg.contains("fill=\"rgb(255,0,0)\""), "{}", p.svg);
219        assert!(!p.svg.contains("rgba("), "{}", p.svg);
220        let p = typeset("x", false, 16.0, [0, 0, 255, 128]).unwrap();
221        assert!(
222            p.svg.contains("fill=\"rgb(0,0,255)\" fill-opacity=\"0.50"),
223            "{}",
224            p.svg
225        );
226    }
227
228    #[test]
229    fn surrounding_whitespace_is_insignificant() {
230        let a = typeset("\n  x + y \n", false, 16.0, BLACK).unwrap();
231        let b = typeset("x + y", false, 16.0, BLACK).unwrap();
232        assert_eq!(a, b);
233    }
234
235    #[test]
236    fn unreadable_tex_is_an_error_that_names_a_byte() {
237        let e = typeset(r"\frac{a", false, 16.0, BLACK).unwrap_err();
238        assert!(!e.message.is_empty());
239        assert!(e.position.is_some(), "{e}");
240        assert!(e.to_string().contains("at byte"), "{e}");
241    }
242
243    #[test]
244    fn an_empty_formula_is_an_empty_picture_not_an_error() {
245        let p = typeset("", false, 16.0, BLACK).unwrap();
246        assert_eq!(p.width, 0.0);
247        assert!(p.svg.starts_with("<svg"));
248    }
249}