Slightly hacky but good enough math rendering in emacs 🪄
1use std::io::{self, Read};
2use std::path::PathBuf;
3use std::sync::OnceLock;
4
5use chrono::Datelike;
6use clap::Parser;
7use typst::diag::{FileError, FileResult};
8use typst::foundations::{Bytes, Datetime};
9use typst::layout::PagedDocument;
10use typst::syntax::{FileId, Source, VirtualPath};
11use typst::text::{Font, FontBook};
12use typst::utils::LazyHash;
13use typst::{Library, World};
14
15#[derive(Parser, Debug)]
16#[command(name = "mathrender", about = "Render LaTeX or Typst math to SVG")]
17struct Args {
18 input: Option<String>,
19
20 #[arg(long, short = 't')]
21 typst: bool,
22
23 #[arg(long, short = 'i')]
24 inline: bool,
25
26 #[arg(long, default_value_t = 16.0)]
27 font_size: f64,
28
29 #[arg(long, default_value = "#000000")]
30 color: String,
31}
32
33#[derive(Debug)]
34enum Error {
35 Compile(String),
36 NoPages,
37}
38
39impl std::fmt::Display for Error {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 match self {
42 Error::Compile(msg) => write!(f, "Typst compile error(s):\n {}", msg),
43 Error::NoPages => write!(f, "Typst produced no pages"),
44 }
45 }
46}
47
48impl std::error::Error for Error {}
49
50struct GlobalFonts {
51 book: LazyHash<FontBook>,
52 fonts: Vec<Font>,
53}
54
55fn get_global_fonts() -> &'static GlobalFonts {
56 static FONTS: OnceLock<GlobalFonts> = OnceLock::new();
57 FONTS.get_or_init(|| {
58 let mut book = FontBook::new();
59 let mut fonts = Vec::new();
60
61 for font_data in typst_assets::fonts() {
62 let bytes = Bytes::new(font_data);
63 for index in 0u32.. {
64 match Font::new(bytes.clone(), index) {
65 Some(font) => {
66 book.push(font.info().clone());
67 fonts.push(font);
68 }
69 None => break,
70 }
71 }
72 }
73 GlobalFonts { book: LazyHash::new(book), fonts }
74 })
75}
76
77fn safe_tex2typst(s: &str, buf: &mut String) {
78 let s = s.trim();
79 if s.is_empty() {
80 return;
81 }
82 // Assertion enforcing pre-condition bounds
83 debug_assert!(s.len() < 10000, "Input to safe_tex2typst is abnormally large");
84
85 buf.push_str(&tex2typst_rs::tex2typst(s));
86}
87
88fn env_matrix_convert(env: &str, content: &str, out_typst_string: &mut String) {
89 let delim = match env {
90 "pmatrix" | "pmatrix*" => "(",
91 "bmatrix" | "bmatrix*" => "[",
92 "vmatrix" | "vmatrix*" => "|",
93 "Bmatrix" | "Bmatrix*" => "{",
94 "Vmatrix" | "Vmatrix*" => "‖",
95 "matrix" | "matrix*" => "",
96 "smallmatrix" => "(",
97 _ => "(",
98 };
99
100 match delim {
101 "" => out_typst_string.push_str("mat(delim: #none, "),
102 "(" => out_typst_string.push_str("mat("),
103 d => { out_typst_string.push_str("mat(delim: \""); out_typst_string.push_str(d); out_typst_string.push_str("\", "); }
104 }
105
106 let mut first_row = true;
107 for row in content.split("\\\\").take(256) {
108 if row.trim().is_empty() { continue; }
109 if !first_row { out_typst_string.push_str("; "); }
110 first_row = false;
111
112 let mut first_cell = true;
113 for cell in row.split('&').take(256) {
114 let cell = cell.trim();
115 if !first_cell { out_typst_string.push_str(", "); }
116 first_cell = false;
117 safe_tex2typst(cell, out_typst_string);
118 }
119 }
120 out_typst_string.push(')');
121}
122
123fn env_cases_convert(content: &str, out_typst_string: &mut String) {
124 out_typst_string.push_str("cases(");
125 let mut first_row = true;
126 for row in content.split("\\\\").take(256) {
127 let row = row.trim();
128 if row.is_empty() { continue; }
129 if !first_row { out_typst_string.push_str(", "); }
130 first_row = false;
131
132 let mut parts = row.splitn(2, '&');
133 let expr = parts.next().expect("Cases row must have content").trim();
134 let cond = parts.next().map(|s| s.trim());
135
136 safe_tex2typst(expr, out_typst_string);
137 if let Some(c) = cond {
138 out_typst_string.push_str(", ");
139 safe_tex2typst(c, out_typst_string);
140 }
141 }
142 out_typst_string.push(')');
143}
144
145fn process_latex_env(env: &str, content: &str, result: &mut String) {
146 debug_assert!(!env.is_empty(), "Environment string cannot be empty");
147
148 match env {
149 "pmatrix" | "pmatrix*" |
150 "bmatrix" | "bmatrix*" |
151 "vmatrix" | "vmatrix*" |
152 "Bmatrix" | "Bmatrix*" |
153 "Vmatrix" | "Vmatrix*" |
154 "matrix" | "matrix*" |
155 "smallmatrix" => env_matrix_convert(env, content, result),
156
157 "cases" | "dcases" => env_cases_convert(content, result),
158
159 "align" | "align*" |
160 "aligned" | "alignat" | "alignat*" |
161 "gather" | "gather*" |
162 "multline" | "multline*" |
163 "split" | "equation" | "equation*" => safe_tex2typst(content, result),
164
165 _ => {
166 result.push_str("\\begin{");
167 result.push_str(env);
168 result.push('}');
169 result.push_str(content);
170 result.push_str("\\end{");
171 result.push_str(env);
172 result.push('}');
173 }
174 }
175}
176
177fn find_env_end<'a>(rest: &'a str, env: &str) -> Option<(&'a str, &'a str)> {
178 let end_search = "\\end{";
179 let mut search_rest = rest;
180 let mut current_offset = 0;
181
182 while let Some(end_begin) = search_rest.find(end_search) {
183 let tag_content_start = end_begin + end_search.len();
184 if search_rest[tag_content_start..].starts_with(env) &&
185 search_rest[tag_content_start + env.len()..].starts_with('}') {
186 let content = &rest[..current_offset + end_begin];
187 let advance = current_offset + end_begin + end_search.len() + env.len() + 1;
188 return Some((content, &rest[advance..]));
189 }
190 let advance = end_begin + end_search.len();
191 search_rest = &search_rest[advance..];
192 current_offset += advance;
193 }
194 None
195}
196
197fn latex_to_typst(input: &str) -> String {
198 let mut result = String::with_capacity(input.len() * 2);
199 let mut rest = input;
200
201 while let Some(begin_pos) = rest.find("\\begin{") {
202 safe_tex2typst(&rest[..begin_pos], &mut result);
203 rest = &rest[begin_pos + 7..];
204
205 let Some(brace_end) = rest.find('}') else {
206 result.push_str("\\begin{");
207 continue;
208 };
209 let env = &rest[..brace_end];
210 rest = &rest[brace_end + 1..];
211
212 if let Some((content, new_rest)) = find_env_end(rest, env) {
213 process_latex_env(env, content, &mut result);
214 rest = new_rest;
215 } else {
216 result.push_str("\\begin{");
217 result.push_str(env);
218 result.push('}');
219 }
220 }
221
222 safe_tex2typst(rest, &mut result);
223 result
224}
225
226fn get_global_library() -> &'static LazyHash<Library> {
227 static LIBRARY: OnceLock<LazyHash<Library>> = OnceLock::new();
228 LIBRARY.get_or_init(|| {
229 LazyHash::new(typst_library::LibraryBuilder::default().build())
230 })
231}
232
233struct MathWorld {
234 source: Source,
235}
236
237impl MathWorld {
238 fn new(source_text: String) -> Self {
239 let main_id = FileId::new(None, VirtualPath::new("/main.typ"));
240 let source = Source::new(main_id, source_text);
241
242 Self {
243 source,
244 }
245 }
246}
247
248impl World for MathWorld {
249 fn library(&self) -> &LazyHash<Library> { get_global_library() }
250 fn book(&self) -> &LazyHash<FontBook> { &get_global_fonts().book }
251 fn main(&self) -> FileId { self.source.id() }
252
253 fn source(&self, id: FileId) -> FileResult<Source> {
254 if id == self.source.id() {
255 Ok(self.source.clone())
256 } else {
257 Err(FileError::NotFound(PathBuf::from("<virtual>")))
258 }
259 }
260
261 fn file(&self, _id: FileId) -> FileResult<Bytes> {
262 Err(FileError::NotFound(PathBuf::from("<virtual>")))
263 }
264
265 fn font(&self, index: usize) -> Option<Font> {
266 get_global_fonts().fonts.get(index).cloned()
267 }
268
269 fn today(&self, offset: Option<i64>) -> Option<Datetime> {
270 let now = chrono::Local::now();
271 let _ = offset;
272 Datetime::from_ymd(now.year(), now.month() as u8, now.day() as u8)
273 }
274}
275
276fn build_typst_source(math_expr: &str, display: bool, font_size: f64, color: &str) -> String {
277 let wrapped = if display {
278 format!("$ {} $", math_expr)
279 } else {
280 format!("${}$", math_expr)
281 };
282
283 // 0.5em margin prevents clipping of subscripts and matrices.
284 format!(
285 "#set page(width: auto, height: auto, margin: 0.5em, fill: none)\n\
286 #set text(size: {font_size}pt, fill: rgb(\"{color}\"))\n\
287 {wrapped}"
288 )
289}
290
291fn render_to_svg(
292 input: &str,
293 is_typst: bool,
294 display: bool,
295 font_size: f64,
296 color: &str,
297) -> Result<String, Error> {
298
299 let typst_math = if is_typst {
300 input.to_owned()
301 } else {
302 latex_to_typst(input)
303 };
304
305 let source = build_typst_source(&typst_math, display, font_size, color);
306 let world = MathWorld::new(source);
307 let compiled = typst::compile::<PagedDocument>(&world);
308
309 let document: PagedDocument = compiled.output.map_err(|errors| {
310 let msgs: Vec<String> = errors.iter().map(|e| e.message.to_string()).collect();
311 Error::Compile(msgs.join("\n "))
312 })?;
313
314 let page = document.pages.first().ok_or(Error::NoPages)?;
315 Ok(typst_svg::svg(page))
316}
317
318fn main() {
319 let args = Args::parse();
320
321 let input = match args.input {
322 Some(s) => s,
323 None => {
324 let mut buf = String::new();
325 io::stdin().read_to_string(&mut buf).expect("Failed to read stdin");
326 buf.trim().to_owned()
327 }
328 };
329
330 if input.is_empty() {
331 eprintln!("Error: no input provided.");
332 std::process::exit(1);
333 }
334
335 match render_to_svg(&input, args.typst, !args.inline, args.font_size, &args.color) {
336 Ok(svg) => print!("{}", svg),
337 Err(e) => {
338 eprintln!("Error: {e}");
339 std::process::exit(1);
340 }
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn test_safe_tex2typst() {
350 let mut buf = String::new();
351 safe_tex2typst("x = 1", &mut buf);
352 assert!(buf.contains("x = 1"));
353 }
354
355 #[test]
356 fn test_convert_matrix() {
357 let mut buf = String::new();
358 env_matrix_convert("pmatrix", "1 & 2 \\\\ 3 & 4", &mut buf);
359 assert_eq!(buf, "mat(1, 2; 3, 4)");
360 }
361
362 #[test]
363 fn test_convert_cases() {
364 let mut buf = String::new();
365 env_cases_convert("x & y \\\\ a & b", &mut buf);
366 assert!(buf.contains("cases("));
367 assert!(buf.contains("x"));
368 assert!(buf.contains("y"));
369 }
370
371 #[test]
372 fn test_latex_to_typst() {
373 let input = "x + \\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix} = y";
374 let result = latex_to_typst(input);
375 assert!(result.contains("mat(1, 2; 3, 4)"));
376 }
377}