1use 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
18const FONT: &[u8] = include_bytes!("../fonts/NewCMMath-Book.otf");
20
21pub const LAYOUT_PT: f64 = 12.0;
23
24#[derive(Debug, Clone, PartialEq)]
26pub struct MathSvg {
27 pub svg: String,
29 pub width: f64,
31 pub height: f64,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct MathError {
38 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
50pub 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
132struct 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 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 #[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}