Skip to main content

oxframe_math/
lib.rs

1//! Typst math to SVG, for the `Math` node of `oxframe` (feature `math`).
2//!
3//! The formula is laid out by [Typst](https://typst.app) with the bundled
4//! New Computer Modern Math font, so it looks the same on every machine
5//! and needs no TeX install. Glyphs come out as paths in reading order.
6
7use std::collections::HashMap;
8use std::sync::{Mutex, OnceLock};
9
10use typst::diag::{FileError, FileResult};
11use typst::foundations::{Bytes, Datetime};
12use typst::syntax::{FileId, Source};
13use typst::text::{Font, FontBook};
14use typst::utils::LazyHash;
15use typst::{Library, LibraryExt, World};
16use typst_layout::PagedDocument;
17
18/// New Computer Modern Math (GUST Font License, `fonts/LICENSE-GUST.txt`).
19const FONT: &[u8] = include_bytes!("../fonts/NewCMMath-Book.otf");
20
21/// Font size the formula is laid out at, in points (one em).
22pub const LAYOUT_PT: f64 = 12.0;
23
24/// A laid-out formula.
25#[derive(Debug, Clone, PartialEq)]
26pub struct MathSvg {
27    /// SVG document whose view box hugs the formula, in points.
28    pub svg: String,
29    /// Width in points.
30    pub width: f64,
31    /// Height in points.
32    pub height: f64,
33}
34
35/// A formula Typst could not lay out.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct MathError {
38    /// Typst's message, with its hints after it.
39    pub message: String,
40}
41
42impl std::fmt::Display for MathError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.write_str(&self.message)
45    }
46}
47
48impl std::error::Error for MathError {}
49
50/// Lays out `formula` (Typst math syntax, without the `$`) at
51/// [`LAYOUT_PT`] in black. Results are cached per formula.
52///
53/// `display` sets the formula on its own line (larger operators, limits
54/// above and below); inline math is set as in running text.
55///
56/// # Errors
57///
58/// If Typst reports an error, such as an unknown symbol or unbalanced
59/// brackets.
60pub fn to_svg(formula: &str, display: bool) -> Result<MathSvg, MathError> {
61    type Cache = HashMap<(String, bool), Result<MathSvg, MathError>>;
62    static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
63    let cache = CACHE.get_or_init(Default::default);
64    let key = (formula.to_owned(), display);
65    if let Some(hit) = cache.lock().expect("math cache").get(&key) {
66        return hit.clone();
67    }
68    let set = layout(formula, display);
69    cache.lock().expect("math cache").insert(key, set.clone());
70    set
71}
72
73fn layout(formula: &str, display: bool) -> Result<MathSvg, MathError> {
74    let body = if display {
75        format!("$ {formula} $")
76    } else {
77        format!("${formula}$")
78    };
79    let text = format!(
80        "#set page(width: auto, height: auto, margin: 0pt, fill: none)\n\
81         #set text(font: \"New Computer Modern Math\", size: {LAYOUT_PT}pt, fill: black, top-edge: \"ascender\", bottom-edge: \"descender\")\n\
82         {body}\n"
83    );
84    let world = MathWorld {
85        source: Source::detached(text),
86    };
87    let doc = typst::compile::<PagedDocument>(&world)
88        .output
89        .map_err(|errors| MathError {
90            message: errors
91                .iter()
92                .map(|e| {
93                    let mut m = e.message.to_string();
94                    for hint in &e.hints {
95                        m.push_str("; hint: ");
96                        m.push_str(&hint.v);
97                    }
98                    m
99                })
100                .collect::<Vec<_>>()
101                .join("\n"),
102        })?;
103    let page = doc.pages().first().ok_or_else(|| MathError {
104        message: "the formula produced no output".into(),
105    })?;
106    let size = page.frame.size();
107    Ok(MathSvg {
108        svg: typst_svg::svg(page, &typst_svg::SvgOptions::default()),
109        width: size.x.to_pt(),
110        height: size.y.to_pt(),
111    })
112}
113
114struct Shared {
115    library: LazyHash<Library>,
116    book: LazyHash<FontBook>,
117    fonts: Vec<Font>,
118}
119
120fn shared() -> &'static Shared {
121    static SHARED: OnceLock<Shared> = OnceLock::new();
122    SHARED.get_or_init(|| {
123        let fonts: Vec<Font> = Font::iter(Bytes::new(FONT)).collect();
124        Shared {
125            library: LazyHash::new(Library::builder().build()),
126            book: LazyHash::new(FontBook::from_fonts(&fonts)),
127            fonts,
128        }
129    })
130}
131
132/// One formula, one in-memory file, one font. No file or network access.
133struct MathWorld {
134    source: Source,
135}
136
137impl World for MathWorld {
138    fn library(&self) -> &LazyHash<Library> {
139        &shared().library
140    }
141
142    fn book(&self) -> &LazyHash<FontBook> {
143        &shared().book
144    }
145
146    fn main(&self) -> FileId {
147        self.source.id()
148    }
149
150    fn source(&self, id: FileId) -> FileResult<Source> {
151        if id == self.source.id() {
152            Ok(self.source.clone())
153        } else {
154            Err(FileError::AccessDenied)
155        }
156    }
157
158    fn file(&self, _id: FileId) -> FileResult<Bytes> {
159        Err(FileError::AccessDenied)
160    }
161
162    fn font(&self, index: usize) -> Option<Font> {
163        shared().fonts.get(index).cloned()
164    }
165
166    fn today(&self, _offset: Option<typst::foundations::Duration>) -> Option<Datetime> {
167        // No clock: the same source always gives the same picture.
168        None
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn a_formula_becomes_paths() {
178        let set = layout("e^(i pi) + 1 = 0", false).unwrap();
179        assert!(set.svg.starts_with("<svg") && set.svg.contains("<path"));
180        assert!(set.width > set.height && set.height > 6.0, "{set:?}");
181        assert_eq!(set, layout("e^(i pi) + 1 = 0", false).unwrap());
182    }
183
184    #[test]
185    fn display_math_is_taller_for_big_operators() {
186        let inline = to_svg("sum_(k=1)^n k", false).unwrap();
187        let display = to_svg("sum_(k=1)^n k", true).unwrap();
188        assert!(display.height > inline.height);
189    }
190
191    /// The syntax the agent reference (templates/agents/reference.md in
192    /// oxframe-cli) promises.
193    #[test]
194    fn the_documented_examples_set() {
195        for f in [
196            "x^2",
197            "x_i",
198            "sqrt(x)",
199            "(a + b) / c",
200            "sum_(k=1)^n",
201            "integral_0^1",
202            "alpha pi",
203            "vec(1, 2)",
204            "mat(1, 2; 3, 4)",
205            "\"text\"",
206        ] {
207            assert!(to_svg(f, true).is_ok(), "{f}: {:?}", to_svg(f, true));
208        }
209    }
210
211    #[test]
212    fn an_unknown_symbol_is_an_error_with_typst_message() {
213        let err = to_svg("#nosuchthing", false).unwrap_err();
214        assert!(err.message.contains("nosuchthing"), "{err}");
215    }
216}