Slightly hacky but good enough math rendering in emacs 🪄
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at master 377 lines 11 kB view raw
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}