返回 CodeWhale
latex_render.rs
根目录 / crates / tui / src / tui / history / latex_render.rs
1 //! LaTeX math expression rendering for the TUI transcript.
2 //! Renders `$...$` (inline) and `$$...$$` (display) math expressions using
3 //! Unicode approximations for terminal display.
4 use std::collections::HashMap;
5 use std::sync::OnceLock;
6 use unicode_width::UnicodeWidthStr;
7
8 fn is_escaped(bytes: &[u8], idx: usize) -> bool {
9 let mut slashes = 0;
10 let mut cursor = idx;
11 while cursor > 0 && bytes[cursor - 1] == b'\\' {
12 slashes += 1;
13 cursor -= 1;
14 }
15 slashes % 2 == 1
16 }
17 /// Private: find the start of math delimiter ($, $$, \(, \[) in text.
18 fn find_math_start(text: &str) -> Option<usize> {
19 let b = text.as_bytes();
20 for (idx, &byte) in b.iter().enumerate() {
21 if byte == b'$' && !is_escaped(b, idx) {
22 return Some(idx);
23 }
24 if byte == b'\\'
25 && !is_escaped(b, idx)
26 && idx + 1 < b.len()
27 && (b[idx + 1] == b'(' || b[idx + 1] == b'[')
28 {
29 return Some(idx);
30 }
31 }
32 None
33 }
34 /// Private: if text starts with a math delimiter, find closing delimiter and return (end_pos, is_display).
35 fn find_math_end(text: &str) -> Option<(usize, bool)> {
36 let b = text.as_bytes();
37 if b.starts_with(b"$$") {
38 for i in 2..b.len().saturating_sub(1) {
39 if b[i] == b'{' {
40 continue;
41 }
42 if b[i..].starts_with(b"$$") && !is_escaped(b, i) {
43 return Some((i, true));
44 }
45 }
46 } else if b.starts_with(b"$") && !b.starts_with(b"$$") {
47 for (j, &byte) in b[1..].iter().enumerate() {
48 if byte == b'{' {
49 continue;
50 }
51 let i = j + 1;
52 if byte == b'$'
53 && !is_escaped(b, i)
54 && !b
55 .get(i.wrapping_sub(1))
56 .is_some_and(u8::is_ascii_whitespace)
57 && !b.get(i + 1).is_some_and(u8::is_ascii_digit)
58 {
59 return Some((i, false));
60 }
61 }
62 } else if b.starts_with(b"\\[") {
63 for i in 2..b.len().saturating_sub(1) {
64 if b[i] == b'{' {
65 continue;
66 }
67 if b[i..].starts_with(b"\\]") && !is_escaped(b, i) {
68 return Some((i, true));
69 }
70 }
71 } else if b.starts_with(b"\\(") {
72 for i in 2..b.len().saturating_sub(1) {
73 if b[i] == b'{' {
74 continue;
75 }
76 if b[i..].starts_with(b"\\)") && !is_escaped(b, i) {
77 return Some((i, false));
78 }
79 }
80 }
81 None
82 }
83 fn math_delim_offset(text: &str) -> usize {
84 let b = text.as_bytes();
85 if b.starts_with(b"$$") || b.starts_with(b"\\[") || b.starts_with(b"\\(") {
86 2
87 } else {
88 1
89 }
90 }
91 fn render_math_segment(text: &str) -> String {
92 let mut result = String::new();
93 let mut i = 0;
94 while i < text.len() {
95 let remaining = &text[i..];
96 if let Some((end, _is_display)) = find_math_end(remaining) {
97 let offset = math_delim_offset(remaining);
98 let inner = &remaining[offset..end];
99 result.push_str(&render_latex_to_string(inner));
100 let close_len: usize = if remaining[end..].starts_with("\\]")
101 || remaining[end..].starts_with("$$")
102 || remaining[end..].starts_with("\\)")
103 {
104 2
105 } else if remaining.as_bytes().get(end..end + 1) == Some(b"$") {
106 1
107 } else {
108 0
109 };
110 i += end + close_len;
111 } else {
112 let skip = find_math_start(remaining).unwrap_or(remaining.len());
113 if skip == 0 {
114 // Unmatched opening delimiter ($, $$, \(, \[) during streaming:
115 // push it as plain text so the loop can advance.
116 result.push(remaining.chars().next().unwrap_or('$'));
117 i += remaining.chars().next().map(|c| c.len_utf8()).unwrap_or(1);
118 } else {
119 result.push_str(&remaining[..skip]);
120 i += skip;
121 if skip == remaining.len() {
122 break;
123 }
124 }
125 }
126 }
127 result
128 }
129
130 /// Replace math delimiters with plain Unicode while preserving Markdown code.
131 ///
132 /// Fast path (#perf-r5): the overwhelming majority of streamed content
133 /// contains no math delimiters at all. A single byte scan for the three
134 /// opening delimiters (`$`, `\(`, `\[`) decides between borrowing the input
135 /// untouched and running the full transform, so the per-chunk streaming
136 /// render avoids allocating a full-content copy on every update when no
137 /// math is present.
138 pub fn render_latex_in_text(text: &str) -> std::borrow::Cow<'_, str> {
139 // Math can only start at '$' (incl. '$$') or the two-byte '\(' and '\['.
140 // Scanning bytes directly avoids a regex; any hit falls back to the
141 // full transform below, which re-verifies delimiters precisely.
142 let has_delim = text
143 .as_bytes()
144 .iter()
145 .enumerate()
146 .any(|(idx, &byte)| match byte {
147 b'$' => true,
148 b'\\' => matches!(text.as_bytes().get(idx + 1), Some(b'(') | Some(b'[')),
149 _ => false,
150 });
151 if !has_delim {
152 return std::borrow::Cow::Borrowed(text);
153 }
154 let mut result = String::with_capacity(text.len());
155 let mut cursor = 0;
156
157 while cursor < text.len() {
158 let Some(tick_offset) = text[cursor..].find('`') else {
159 result.push_str(&render_math_segment(&text[cursor..]));
160 break;
161 };
162 let tick_start = cursor + tick_offset;
163 result.push_str(&render_math_segment(&text[cursor..tick_start]));
164
165 let tick_count = text[tick_start..]
166 .bytes()
167 .take_while(|byte| *byte == b'`')
168 .count();
169 let delimiter = "`".repeat(tick_count);
170 let content_start = tick_start + tick_count;
171 if let Some(close_offset) = text[content_start..].find(&delimiter) {
172 let code_end = content_start + close_offset + tick_count;
173 result.push_str(&text[tick_start..code_end]);
174 cursor = code_end;
175 } else {
176 result.push_str(&text[tick_start..]);
177 break;
178 }
179 }
180
181 std::borrow::Cow::Owned(result)
182 }
183
184 // --- Environment rendering ---
185
186 /// Render a `\begin{name}...\end{name}` block.
187 /// `content` is everything between the braces.
188 fn render_environment(env_name: &str, content: &str) -> String {
189 match env_name {
190 "aligned" | "align" | "gather" | "eqnarray" | "split" => render_aligned(content),
191 "pmatrix" | "bmatrix" | "vmatrix" | "Bmatrix" | "matrix" | "smallmatrix" => {
192 render_matrix(env_name, content)
193 }
194 "array" => render_array(content),
195 "cases" | "dcases" => render_cases(content, false),
196 "rcases" | "drcases" => render_cases(content, true),
197 _ => {
198 // Unknown environment: pass through raw
199 format!("\\begin{{{env_name}}}{content}\\end{{{env_name}}}")
200 }
201 }
202 }
203
204 /// Split a multi-row env content into rows (`\\` separator), each rendered.
205 /// `row_fn` is called for each parsed row (list of cell strings).
206 fn parse_rows<F>(content: &str, mut row_fn: F)
207 where
208 F: FnMut(Vec<String>),
209 {
210 // Split by \\ (but be careful: \\\\ is an escaped backslash, not a line break)
211 let mut current = String::new();
212 let mut chars = content.chars().peekable();
213 while let Some(ch) = chars.next() {
214 if ch == '\\' {
215 if chars.peek() == Some(&'\\') {
216 // Line break marker
217 chars.next(); // consume second \
218 // If followed by optional whitespace and an optional * (\\*)
219 while matches!(chars.peek(), Some(&' ') | Some(&'\t')) {
220 chars.next();
221 }
222 if chars.peek() == Some(&'*') {
223 chars.next();
224 }
225 row_fn(parse_row_cells(&current));
226 current.clear();
227 } else if chars.peek() == Some(&'[') || chars.peek() == Some(&'{') {
228 // --- Spacing ---
229 // --- Spacing ---
230 if chars.peek() == Some(&'[') {
231 chars.next();
232 while let Some(&c) = chars.peek() {
233 if c == ']' {
234 chars.next();
235 break;
236 }
237 chars.next();
238 }
239 } else if chars.peek() == Some(&'{') {
240 let _ = read_braced_chars(&mut chars);
241 }
242 row_fn(parse_row_cells(&current));
243 current.clear();
244 } else {
245 current.push('\\');
246 }
247 } else if ch == '\n' {
248 // Newlines in environments often act as row separators
249 // but not inside braces
250 // Simple approach: treat bare \n as space
251 if !current.is_empty() && !current.ends_with(' ') {
252 current.push(' ');
253 }
254 } else {
255 current.push(ch);
256 }
257 }
258 // Last row
259 row_fn(parse_row_cells(&current));
260 }
261
262 /// Split a single row into cells by `&`.
263 fn parse_row_cells(row: &str) -> Vec<String> {
264 // Split by & but skip escaped \&
265 let mut cells = Vec::new();
266 let mut current = String::new();
267 let mut chars = row.chars().peekable();
268 while let Some(ch) = chars.next() {
269 if ch == '&' {
270 cells.push(current.trim().to_string());
271 current.clear();
272 } else if ch == '\\' && chars.peek() == Some(&'&') {
273 // Escaped ampersand
274 current.push('&');
275 chars.next();
276 } else {
277 current.push(ch);
278 }
279 }
280 cells.push(current.trim().to_string());
281 cells
282 }
283
284 /// Aligned equations: align at `&` markers.
285 fn render_aligned(content: &str) -> String {
286 let mut rows: Vec<Vec<String>> = Vec::new();
287 parse_rows(content, |cells| rows.push(cells));
288
289 if rows.is_empty() {
290 return String::new();
291 }
292
293 // Determine max columns
294 let max_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
295 if max_cols == 0 {
296 return String::new();
297 }
298
299 // Double-pass: render each cell and measure widths
300 let mut rendered: Vec<Vec<String>> = Vec::new();
301 let mut col_widths: Vec<usize> = vec![0; max_cols];
302
303 for row in &rows {
304 let mut rendered_row = Vec::new();
305 for (ci, cell) in row.iter().enumerate() {
306 let rendered_cell = render_latex_to_string(cell);
307 let w = UnicodeWidthStr::width(rendered_cell.as_str());
308 if ci < max_cols && w > col_widths[ci] {
309 col_widths[ci] = w;
310 }
311 rendered_row.push(rendered_cell);
312 }
313 // Pad missing cells
314 while rendered_row.len() < max_cols {
315 rendered_row.push(String::new());
316 }
317 rendered.push(rendered_row);
318 }
319
320 // Second pass: assemble with padding
321 let mut result = String::new();
322 for (ri, row) in rendered.iter().enumerate() {
323 if ri > 0 {
324 result.push('\n');
325 }
326 for ci in 0..max_cols {
327 if ci > 0 {
328 let pad =
329 col_widths[ci - 1].saturating_sub(UnicodeWidthStr::width(row[ci - 1].as_str()));
330 for _ in 0..pad {
331 result.push(' ');
332 }
333 result.push_str(" ");
334 }
335 result.push_str(&row[ci]);
336 }
337 }
338
339 result
340 }
341
342 /// --- Brackets ---
343 fn render_matrix(env_name: &str, content: &str) -> String {
344 let mut rows: Vec<Vec<String>> = Vec::new();
345 parse_rows(content, |cells| rows.push(cells));
346
347 if rows.is_empty() {
348 return String::new();
349 }
350
351 let max_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
352 if max_cols == 0 {
353 return String::new();
354 }
355
356 // Two-pass: render + measure
357 let mut rendered: Vec<Vec<String>> = Vec::new();
358 let mut col_widths: Vec<usize> = vec![0; max_cols];
359
360 for row in &rows {
361 let mut rendered_row = Vec::new();
362 for (ci, cell) in row.iter().enumerate() {
363 let rendered_cell = render_latex_to_string(cell);
364 let w = UnicodeWidthStr::width(rendered_cell.as_str());
365 if ci < max_cols && w > col_widths[ci] {
366 col_widths[ci] = w;
367 }
368 rendered_row.push(rendered_cell);
369 }
370 while rendered_row.len() < max_cols {
371 rendered_row.push(String::new());
372 }
373 rendered.push(rendered_row);
374 }
375
376 // Build each row with proper padding
377 let mut cell_strings: Vec<String> = Vec::new();
378 for row in &rendered {
379 let mut line = String::new();
380 for ci in 0..max_cols {
381 if ci > 0 {
382 line.push(' ');
383 }
384 let cell = &row[ci];
385 line.push_str(cell);
386 let pad = col_widths[ci].saturating_sub(UnicodeWidthStr::width(cell.as_str()));
387 for _ in 0..pad {
388 line.push(' ');
389 }
390 }
391 cell_strings.push(line);
392 }
393
394 match env_name {
395 "pmatrix" => surround_with("(", ")", &cell_strings, 1),
396 "bmatrix" => surround_with("[", "]", &cell_strings, 1),
397 "vmatrix" => surround_with("\u{2502}", "\u{2502}", &cell_strings, 1),
398 "Bmatrix" => surround_with("{", "}", &cell_strings, 1),
399 "smallmatrix" => surround_with("(", ")", &cell_strings, 0),
400 _ => {
401 // --- Brackets ---
402 let mut result = String::new();
403 for (ri, s) in cell_strings.iter().enumerate() {
404 if ri > 0 {
405 result.push('\n');
406 }
407 result.push_str(s);
408 }
409 result
410 }
411 }
412 }
413
414 /// --- Brackets ---
415 fn surround_with(left: &str, right: &str, rows: &[String], pad: usize) -> String {
416 if rows.is_empty() {
417 return format!("{left}{right}");
418 }
419 let mut result = String::new();
420 if rows.len() == 1 {
421 result.push_str(left);
422 for _ in 0..pad {
423 result.push(' ');
424 }
425 result.push_str(&rows[0]);
426 for _ in 0..pad {
427 result.push(' ');
428 }
429 result.push_str(right);
430 return result;
431 }
432 // Multi-row: brackets on their own lines
433 result.push_str(left);
434 result.push('\n');
435 for (ri, s) in rows.iter().enumerate() {
436 if ri > 0 {
437 result.push('\n');
438 }
439 for _ in 0..pad {
440 result.push(' ');
441 }
442 result.push_str(s);
443 }
444 result.push('\n');
445 result.push_str(right);
446 result
447 }
448
449 /// Array environment: parse column spec and render table with vertical bars.
450 fn render_array(content: &str) -> String {
451 let trimmed = content.trim_start();
452 let (col_spec, body) = if let Some(after_brace) = trimmed.strip_prefix('{') {
453 let close = after_brace.find('}').map(|i| i + 1).unwrap_or(0);
454 if close > 0 {
455 (&after_brace[..close], after_brace[close..].trim_start())
456 } else {
457 ("", trimmed)
458 }
459 } else {
460 ("", trimmed)
461 };
462 let mut has_vline_start = false;
463 let mut vlines = Vec::new();
464 let mut cols = Vec::new();
465 for ch in col_spec.chars() {
466 match ch {
467 'c' | 'l' | 'r' => cols.push(ch),
468 '|' => {
469 if cols.is_empty() {
470 has_vline_start = true;
471 } else {
472 vlines.push(cols.len());
473 }
474 }
475 _ => {}
476 }
477 }
478 let n = cols.len();
479 if n == 0 {
480 return body.to_string();
481 }
482 let mut rows = Vec::new();
483 parse_rows(body, |cells| rows.push(cells));
484 let mut result = String::new();
485 for (ri, row) in rows.iter().enumerate() {
486 if ri > 0 {
487 result.push('\n');
488 }
489 if has_vline_start {
490 result.push_str("| ");
491 }
492 for ci in 0..n {
493 if ci > 0 {
494 result.push(if vlines.contains(&ci) { '|' } else { ' ' });
495 result.push(' ');
496 }
497 result.push_str(&render_latex_to_string(
498 row.get(ci).unwrap_or(&String::new()),
499 ));
500 }
501 if vlines.contains(&n) || has_vline_start {
502 result.push_str(" |");
503 }
504 }
505 result
506 }
507
508 /// Piecewise functions with cases environment.
509 fn render_cases(content: &str, right_brace: bool) -> String {
510 let mut rows: Vec<Vec<String>> = Vec::new();
511 parse_rows(content, |cells| rows.push(cells));
512
513 if rows.is_empty() {
514 return String::new();
515 }
516
517 let mut rendered_rows: Vec<(String, Option<String>)> = Vec::new();
518 let mut left_width = 0;
519
520 for cells in &rows {
521 let left = render_latex_to_string(cells.first().map(|s| s.as_str()).unwrap_or(""));
522 let left_w = UnicodeWidthStr::width(left.as_str());
523 if left_w > left_width {
524 left_width = left_w;
525 }
526 let right = if cells.len() > 1 {
527 let r = render_latex_to_string(&cells[1]);
528 Some(r)
529 } else {
530 None
531 };
532 rendered_rows.push((left, right));
533 }
534
535 let n = rendered_rows.len();
536 let mut result = String::new();
537 for (ri, (left, right)) in rendered_rows.iter().enumerate() {
538 if ri > 0 {
539 result.push('\n');
540 }
541 if !right_brace {
542 result.push_str(match ri {
543 0 => "\u{23a7} ",
544 _ if ri == n - 1 => "\u{23a9} ",
545 _ => "\u{23a8} ",
546 });
547 }
548
549 // Left part + padding
550 let left_pad = left_width.saturating_sub(UnicodeWidthStr::width(left.as_str()));
551 result.push_str(left);
552 for _ in 0..left_pad {
553 result.push(' ');
554 }
555
556 if let Some(cond) = right {
557 result.push_str(", ");
558 result.push_str(cond);
559 }
560 }
561 result
562 }
563
564 // --- Helper: read_braced for chars iterator ---
565
566 fn read_braced_chars(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
567 let mut s = String::new();
568 let mut depth: u32 = 0;
569 if chars.next_if_eq(&'{').is_some() {
570 depth = 1;
571 }
572 while let Some(&c) = chars.peek() {
573 match c {
574 '{' => {
575 depth += 1;
576 s.push(c);
577 chars.next();
578 }
579 '}' => {
580 depth = depth.saturating_sub(1);
581 chars.next();
582 if depth == 0 {
583 break;
584 }
585 s.push('}');
586 }
587 _ => {
588 s.push(c);
589 chars.next();
590 }
591 }
592 }
593 s
594 }
595
596 // --- Styled symbols ---
597
598 fn render_styled_symbol(
599 command: &str,
600 chars: &mut std::iter::Peekable<std::str::Chars>,
601 out: &mut String,
602 ) {
603 let argument = read_braced_chars(chars);
604 let rendered = match (command, argument.as_str()) {
605 ("mathbb", "R") => Some("\u{211d}"),
606 ("mathbb", "C") => Some("\u{2102}"),
607 ("mathbb", "N") => Some("\u{2115}"),
608 ("mathbb", "Q") => Some("\u{211a}"),
609 ("mathbb", "Z") => Some("\u{2124}"),
610 ("mathbb", "P") => Some("\u{2119}"),
611 ("mathbb", "H") => Some("\u{210d}"),
612 ("mathbb", "F") => Some("\u{1d53b}"),
613 ("mathcal", "L") => Some("\u{2112}"),
614 ("mathcal", "H") => Some("\u{210b}"),
615 ("mathcal", "R") => Some("\u{211b}"),
616 ("mathcal", "A") => Some("\u{1d49c}"),
617 ("mathcal", "B") => Some("\u{212c}"),
618 ("mathcal", "C") => Some("\u{212d}"),
619 ("mathcal", "D") => Some("\u{1d49f}"),
620 ("mathcal", "E") => Some("\u{2130}"),
621 ("mathcal", "F") => Some("\u{2131}"),
622 ("mathcal", "I") => Some("\u{2110}"),
623 ("mathcal", "M") => Some("\u{2133}"),
624 ("mathcal", "O") => Some("\u{1d4aa}"),
625 ("mathcal", "P") => Some("\u{1d4ab}"),
626 ("mathcal", "S") => Some("\u{1d4ae}"),
627 ("mathcal", "T") => Some("\u{1d4af}"),
628 ("mathcal", "Z") => Some("\u{2128}"),
629 _ => None,
630 };
631 if let Some(symbol) = rendered {
632 out.push_str(symbol);
633 } else {
634 out.push('\\');
635 out.push_str(command);
636 out.push('{');
637 out.push_str(&argument);
638 out.push('}');
639 }
640 }
641
642 // --- Main render function ---
643
644 /// Read a braced group `{...}` from an index-based cursor in a string.
645 /// Returns (content_string, new_cursor_position).
646 fn read_braced_at(input: &str, start: usize) -> Option<(String, usize)> {
647 let bytes = input.as_bytes();
648 let mut pos = start;
649 if pos >= input.len() || bytes[pos] != b'{' {
650 return None;
651 }
652 pos += 1; // skip {
653 let mut depth: u32 = 1;
654 let mut content = String::new();
655 while pos < input.len() {
656 let ch = input[pos..].chars().next()?;
657 let byte_len = ch.len_utf8();
658 match ch {
659 '{' => {
660 depth += 1;
661 if depth > 1 {
662 content.push('{');
663 }
664 }
665 '}' => {
666 depth -= 1;
667 if depth == 0 {
668 return Some((content, pos + 1));
669 }
670 content.push('}');
671 }
672 _ => content.push(ch),
673 }
674 pos += byte_len;
675 }
676 None
677 }
678
679 /// Main LaTeX-to-Unicode rendering.
680 fn render_latex_to_string(latex: &str) -> String {
681 let input = latex.trim();
682 let mut out = String::new();
683 let mut pos = 0;
684
685 while pos < input.len() {
686 let remaining = &input[pos..];
687
688 // 1. Environment detection: \begin{name}...\end{name}
689 if let Some(env_rendered) = try_render_env(remaining) {
690 let (rendered, consumed) = env_rendered;
691 // Start multi-line envs on a fresh line for alignment
692 let needs_newline = rendered.starts_with('\u{23a7}') // cases ??
693 || rendered.starts_with('(') // pmatrix
694 || rendered.starts_with('[') // bmatrix
695 || rendered.starts_with('\u{2502}'); // vmatrix
696 if needs_newline && !out.ends_with('\n') {
697 out.push('\n');
698 }
699 out.push_str(&rendered);
700 pos += consumed;
701 continue;
702 }
703
704 let ch = remaining.chars().next().unwrap();
705 let ch_len = ch.len_utf8();
706
707 match ch {
708 '\\' => {
709 // Read command name
710 let rest = &input[pos + 1..];
711 let cmd_end = rest
712 .find(|c: char| !c.is_ascii_alphabetic())
713 .unwrap_or(rest.len());
714 let cmd = &rest[..cmd_end];
715 let _after_cmd = cmd_end;
716
717 if cmd.is_empty() {
718 // Escape sequence for special chars
719 if let Some(&next) = rest.as_bytes().first() {
720 match next {
721 b'{' | b'}' | b'$' | b'%' | b'#' | b'&' | b'_' | b' ' => {
722 // Consume the escape
723 let skip = 1 + 1; // \ + char
724 if next == b' ' {
725 // \ (backslash-space) is a space
726 out.push(' ');
727 }
728 // otherwise just skip (it's an escaped char)
729 pos += skip;
730 continue;
731 }
732 _ => {
733 // Unknown single-char escape 闁?output the char
734 let char_len =
735 rest.chars().next().map(|c| c.len_utf8()).unwrap_or(1);
736 out.push(rest.chars().next().unwrap_or(ch));
737 pos += 1 + char_len;
738 continue;
739 }
740 }
741 }
742 pos += 1;
743 continue;
744 }
745
746 let cmd_len = cmd.len();
747 let _total_cmd_start = pos;
748 let total_cmd_end = pos + 1 + cmd_len; // \ + name
749
750 match cmd {
751 // --- Environments (handled above, just in case) ---
752 "begin" => {
753 // Should have been caught by try_render_env above.
754 // Fallback: try inline parsing
755 if let Some((env_name, after_name)) = read_braced_at(input, total_cmd_end) {
756 let end_tag = format!("\\end{{{env_name}}}");
757 let search_from = &input[after_name..];
758 if let Some(end_rel) = search_from.find(&end_tag) {
759 let env_content = &search_from[..end_rel];
760 let rendered = render_environment(&env_name, env_content);
761 out.push_str(&rendered);
762 pos = after_name + end_rel + end_tag.len();
763 continue;
764 }
765 out.push_str(&format!("\\begin{{{env_name}}}"));
766 }
767 out.push_str("\\begin");
768 pos = total_cmd_end;
769 }
770 "end" => {
771 // Shouldn't be reached; output raw.
772 let after_end = pos + 4;
773 if let Some((env_name, after_name)) = read_braced_at(input, after_end) {
774 out.push_str(&format!("\\end{{{env_name}}}"));
775 pos = after_name;
776 } else {
777 out.push_str("\\end");
778 pos = after_end;
779 }
780 }
781 // --- Text ---
782 "text" | "mathrm" | "mathit" | "mathsf" | "textrm" | "textit" | "textbf" => {
783 if let Some((arg, new_pos)) = read_braced_at(input, total_cmd_end) {
784 out.push_str(&arg);
785 pos = new_pos;
786 } else {
787 // No braces, try single char
788 let next = &input[total_cmd_end..].chars().next();
789 if let Some(c) = next {
790 out.push(*c);
791 pos = total_cmd_end + c.len_utf8();
792 } else {
793 out.push_str(cmd);
794 pos = total_cmd_end;
795 }
796 }
797 }
798 // --- Accents ---
799 "hat" | "bar" | "tilde" | "dot" | "ddot" | "vec" | "breve" | "check"
800 | "acute" | "grave" => {
801 if let Some((arg, new_pos)) = read_braced_at(input, total_cmd_end) {
802 let rendered = render_latex_to_string(&arg);
803 let accent = match cmd {
804 "hat" => "\u{0302}",
805 "bar" => "\u{0304}",
806 "tilde" => "\u{0303}",
807 "dot" => "\u{0307}",
808 "ddot" => "\u{0308}",
809 "vec" => "\u{20d7}",
810 "breve" => "\u{0306}",
811 "check" => "\u{030c}",
812 "acute" => "\u{0301}",
813 "grave" => "\u{0300}",
814 _ => unreachable!(),
815 };
816 out.push_str(&rendered);
817 out.push_str(accent);
818 pos = new_pos;
819 } else {
820 out.push_str(&format!("\\{cmd}"));
821 pos = total_cmd_end;
822 }
823 }
824 "operatorname" => {
825 if let Some((arg, new_pos)) = read_braced_at(input, total_cmd_end) {
826 out.push_str(&arg);
827 pos = new_pos;
828 } else {
829 out.push_str("\\operatorname");
830 pos = total_cmd_end;
831 }
832 }
833 // --- Underbrace / Overbrace (passthrough) ---
834 "underbrace" | "overbrace" | "underbracket" | "overbracket" => {
835 if let Some((arg, after_arg)) = read_braced_at(input, total_cmd_end) {
836 out.push_str(&render_latex_to_string(&arg));
837 pos = after_arg;
838 } else {
839 out.push_str(&format!("\\{cmd}"));
840 pos = total_cmd_end;
841 }
842 }
843 // --- Vertical/horizontal phantom (no-op) ---
844 "vphantom" | "hphantom" | "phantom" => {
845 if let Some((_, after_arg)) = read_braced_at(input, total_cmd_end) {
846 pos = after_arg;
847 } else {
848 out.push_str(&format!("\\{cmd}"));
849 pos = total_cmd_end;
850 }
851 }
852 // --- Substack ---
853 "substack" => {
854 if let Some((arg, after_arg)) = read_braced_at(input, total_cmd_end) {
855 out.push_str(&arg);
856 pos = after_arg;
857 } else {
858 out.push_str(&format!("\\{cmd}"));
859 pos = total_cmd_end;
860 }
861 }
862 // --- Fonts ---
863 "mathbf" | "bf" => {
864 if let Some((arg, new_pos)) = read_braced_at(input, total_cmd_end) {
865 out.push_str(&render_latex_to_string(&arg));
866 pos = new_pos;
867 } else {
868 let next = &input[total_cmd_end..].chars().next();
869 if let Some(c) = next {
870 out.push(*c);
871 pos = total_cmd_end + c.len_utf8();
872 } else {
873 out.push_str(cmd);
874 pos = total_cmd_end;
875 }
876 }
877 }
878 // --- Brackets ---
879 "left" | "bigl" | "Bigl" | "biggl" | "Biggl" => {
880 // Consume the next token (bracket/pipe/dot) and output it
881 let after = &input[total_cmd_end..].trim_start();
882 if let Some(next) = after.chars().next() {
883 if next == '.' {
884 // \left. 闁?invisible delimiter, skip
885 } else {
886 out.push(next);
887 }
888 let skip = after.len() - after.trim_start().len() + next.len_utf8();
889 pos = total_cmd_end + skip;
890 } else {
891 pos = total_cmd_end;
892 }
893 }
894 "right" | "bigr" | "Bigr" | "biggr" | "Biggr" => {
895 let after = &input[total_cmd_end..].trim_start();
896 if let Some(next) = after.chars().next() {
897 if next == '.' {
898 // \right. 闁?invisible delimiter, skip
899 } else {
900 out.push(next);
901 }
902 let skip = after.len() - after.trim_start().len() + next.len_utf8();
903 pos = total_cmd_end + skip;
904 } else {
905 pos = total_cmd_end;
906 }
907 }
908 "big" | "Big" | "bigg" | "Bigg" => {
909 // Size modifiers 闁?skip them, the next token is what matters
910 let after = &input[total_cmd_end..].trim_start();
911 if let Some(next) = after.chars().next() {
912 out.push(next);
913 pos = total_cmd_end
914 + (after.len() - after.trim_start().len())
915 + next.len_utf8();
916 } else {
917 pos = total_cmd_end;
918 }
919 }
920 // --- Spacing ---
921 "quad" => {
922 out.push_str(" ");
923 pos = total_cmd_end;
924 }
925 "qquad" => {
926 out.push_str(" ");
927 pos = total_cmd_end;
928 }
929 "," | "thinspace" => {
930 out.push(' ');
931 pos = total_cmd_end;
932 }
933 ";" | "thickspace" => {
934 out.push_str(" ");
935 pos = total_cmd_end;
936 }
937 "!" | "negthinspace" => {
938 // Negative space: just skip
939 pos = total_cmd_end;
940 }
941 ":" | "medspace" => {
942 out.push_str(" ");
943 pos = total_cmd_end;
944 }
945 " " | "space" | "enspace" => {
946 out.push(' ');
947 pos = total_cmd_end;
948 }
949 // --- Styled symbols ---
950 "mathbb" | "mathcal" => {
951 let before = out.len();
952 if let Some((arg, after_arg)) = read_braced_at(input, total_cmd_end) {
953 let mut chars = arg.chars().peekable();
954 render_styled_symbol(cmd, &mut chars, &mut out);
955 if out.len() == before {
956 // render_styled_symbol didn't match
957 out.push_str(&format!("\\{cmd}{{{arg}}}"));
958 }
959 pos = after_arg;
960 } else {
961 out.push_str(&format!("\\{cmd}"));
962 pos = total_cmd_end;
963 }
964 }
965 // --- Fractions ---
966 "frac" | "dfrac" | "tfrac" | "cfrac" => {
967 if let Some((num_s, after_num)) = read_braced_at(input, total_cmd_end) {
968 if let Some((den_s, after_den)) = read_braced_at(input, after_num) {
969 let n = render_latex_to_string(&num_s);
970 let d = render_latex_to_string(&den_s);
971 out.push_str(&format!("({n}/{d})"));
972 pos = after_den;
973 } else {
974 out.push_str(&format!("({num_s}/?)"));
975 pos = after_num;
976 }
977 } else {
978 out.push_str(&format!("\\{cmd}"));
979 pos = total_cmd_end;
980 }
981 }
982 // --- Binomial coefficient ---
983 "binom" => {
984 if let Some((top_s, after_top)) = read_braced_at(input, total_cmd_end)
985 && let Some((bot_s, after_bot)) = read_braced_at(input, after_top)
986 {
987 out.push_str(&format!(
988 "({}/{})",
989 render_latex_to_string(&top_s),
990 render_latex_to_string(&bot_s)
991 ));
992 pos = after_bot;
993 }
994 }
995 // --- Square root ---
996 "sqrt" => {
997 let after = &input[total_cmd_end..];
998 // Optional [n] root index
999 let (root_text, after_root) =
1000 if let Some(after_lb) = after.strip_prefix('[') {
1001 let end_bracket = after_lb.find(']').map(|i| i + 1);
1002 if let Some(e) = end_bracket {
1003 (Some(&after_lb[..e]), total_cmd_end + e + 1)
1004 } else {
1005 (None, total_cmd_end)
1006 }
1007 } else {
1008 (None, total_cmd_end)
1009 };
1010 if let Some((arg, _new_pos)) = read_braced_at(input, after_root) {
1011 let r = render_latex_to_string(&arg);
1012 if let Some(_root) = root_text {
1013 out.push_str(&format!("\u{221a}({r})"));
1014 } else {
1015 out.push_str(&format!("\u{221a}({r})"));
1016 }
1017 out.push('\u{221a}');
1018 pos = after_root;
1019 }
1020 }
1021 // --- Sum, product, integral ---
1022 "sum" => {
1023 out.push('\u{2211}');
1024 pos = total_cmd_end;
1025 }
1026 "prod" => {
1027 out.push('\u{220f}');
1028 pos = total_cmd_end;
1029 }
1030 "int" => {
1031 out.push('\u{222b}');
1032 pos = total_cmd_end;
1033 }
1034 "iint" => {
1035 out.push('\u{222c}');
1036 pos = total_cmd_end;
1037 }
1038 "iiint" => {
1039 out.push('\u{222d}');
1040 pos = total_cmd_end;
1041 }
1042 "oint" => {
1043 out.push('\u{222e}');
1044 pos = total_cmd_end;
1045 }
1046 "oiint" => {
1047 out.push('\u{222f}');
1048 pos = total_cmd_end;
1049 }
1050 // --- Named operators ---
1051 "lim" => {
1052 out.push_str("lim");
1053 pos = total_cmd_end;
1054 }
1055 "sin" | "cos" | "tan" | "cot" | "sec" | "csc" | "log" | "ln" | "lg" | "exp"
1056 | "det" | "dim" | "ker" | "hom" | "max" | "min" | "sup" | "inf" | "arg"
1057 | "deg" | "mod" | "gcd" | "lcm" | "Pr" | "Var" | "Cov" | "Corr" | "tr"
1058 | "rank" | "Re" | "Im" | "sinh" | "cosh" | "tanh" | "coth" | "arcsin"
1059 | "arccos" | "arctan" => {
1060 out.push_str(cmd);
1061 pos = total_cmd_end;
1062 }
1063 // --- Arrows ---
1064 "to" | "rightarrow" => {
1065 out.push('\u{2192}');
1066 pos = total_cmd_end;
1067 }
1068 "leftarrow" => {
1069 out.push('\u{2190}');
1070 pos = total_cmd_end;
1071 }
1072 "Rightarrow" => {
1073 out.push('\u{21d2}');
1074 pos = total_cmd_end;
1075 }
1076 "Leftarrow" => {
1077 out.push('\u{21d0}');
1078 pos = total_cmd_end;
1079 }
1080 "Leftrightarrow" | "iff" => {
1081 out.push('\u{21d4}');
1082 pos = total_cmd_end;
1083 }
1084 "mapsto" => {
1085 out.push('\u{21a6}');
1086 pos = total_cmd_end;
1087 }
1088 "longrightarrow" => {
1089 out.push('\u{27f6}');
1090 pos = total_cmd_end;
1091 }
1092 "Longrightarrow" => {
1093 out.push('\u{27f9}');
1094 pos = total_cmd_end;
1095 }
1096 "uparrow" => {
1097 out.push('\u{2191}');
1098 pos = total_cmd_end;
1099 }
1100 "downarrow" => {
1101 out.push('\u{2193}');
1102 pos = total_cmd_end;
1103 }
1104 "Uparrow" => {
1105 out.push('\u{21d1}');
1106 pos = total_cmd_end;
1107 }
1108 "Downarrow" => {
1109 out.push('\u{21d3}');
1110 pos = total_cmd_end;
1111 }
1112 "longleftrightarrow" => {
1113 out.push('\u{27f7}');
1114 pos = total_cmd_end;
1115 }
1116 "Longleftrightarrow" => {
1117 out.push('\u{27fa}');
1118 pos = total_cmd_end;
1119 }
1120 "hookrightarrow" => {
1121 out.push('\u{21aa}');
1122 pos = total_cmd_end;
1123 }
1124 "hookleftarrow" => {
1125 out.push('\u{21a9}');
1126 pos = total_cmd_end;
1127 }
1128 "rightharpoonup" => {
1129 out.push('\u{21c0}');
1130 pos = total_cmd_end;
1131 }
1132 "rightharpoondown" => {
1133 out.push('\u{21c1}');
1134 pos = total_cmd_end;
1135 }
1136 "leftharpoonup" => {
1137 out.push('\u{21bc}');
1138 pos = total_cmd_end;
1139 }
1140 "leftharpoondown" => {
1141 out.push('\u{21bd}');
1142 pos = total_cmd_end;
1143 }
1144 "rightleftharpoons" => {
1145 out.push('\u{21cc}');
1146 pos = total_cmd_end;
1147 }
1148 "nrightarrow" => {
1149 out.push('\u{219b}');
1150 pos = total_cmd_end;
1151 }
1152 "nleftarrow" => {
1153 out.push('\u{219a}');
1154 pos = total_cmd_end;
1155 }
1156 // --- Unknown command ---
1157 _ => {
1158 if let Some(sym) = SYMBOLS.get_or_init(build_symbols).get(cmd) {
1159 out.push_str(sym);
1160 pos = total_cmd_end;
1161 // Check for braces after symbol (e.g., \alpha_{i})
1162 // The subscript/superscript will be handled by the
1163 // main loop as _ and ^
1164 } else {
1165 // --- Unknown command ---
1166 out.push('\\');
1167 out.push_str(cmd);
1168 pos = total_cmd_end;
1169 // If followed by {, include the braced argument
1170 if input[pos..].starts_with('{')
1171 && let Some((arg, new_pos)) = read_braced_at(input, pos)
1172 {
1173 out.push('{');
1174 out.push_str(&arg);
1175 out.push('}');
1176 pos = new_pos;
1177 }
1178 }
1179 }
1180 }
1181 }
1182 '_' => {
1183 // Read subscript
1184 let after = &input[pos + 1..];
1185 if after.starts_with('{') {
1186 if let Some((sub, new_pos)) = read_braced_at(input, pos + 1) {
1187 append_subscript(&render_latex_to_string(&sub), &mut out);
1188 pos = new_pos;
1189 } else {
1190 out.push('_');
1191 pos += 1;
1192 }
1193 } else {
1194 // Subscript with command like _\mu _\nu
1195 if let Some(after_bs) = after.strip_prefix('\\') {
1196 let cmd_end = after_bs
1197 .find(|c: char| !c.is_ascii_alphabetic())
1198 .unwrap_or(after_bs.len());
1199 let rendered = render_latex_to_string(&after[..1 + cmd_end]);
1200 append_subscript(&rendered, &mut out);
1201 pos += 1 + 1 + cmd_end;
1202 } else {
1203 let next = after.chars().next();
1204 if let Some(c) = next {
1205 append_subscript(&c.to_string(), &mut out);
1206 pos += 1 + c.len_utf8();
1207 } else {
1208 out.push('_');
1209 pos += 1;
1210 }
1211 }
1212 }
1213 }
1214 '^' => {
1215 // Read superscript
1216 let after = &input[pos + 1..];
1217 if after.starts_with('{') {
1218 if let Some((sup, new_pos)) = read_braced_at(input, pos + 1) {
1219 append_superscript(&render_latex_to_string(&sup), &mut out);
1220 pos = new_pos;
1221 } else {
1222 out.push('^');
1223 pos += 1;
1224 }
1225 } else {
1226 // Superscript with command like ^\dagger ^\rho
1227 if let Some(after_bs) = after.strip_prefix('\\') {
1228 let cmd_end = after_bs
1229 .find(|c: char| !c.is_ascii_alphabetic())
1230 .unwrap_or(after_bs.len());
1231 let rendered = render_latex_to_string(&after[..1 + cmd_end]);
1232 append_superscript(&rendered, &mut out);
1233 pos += 1 + 1 + cmd_end;
1234 } else {
1235 let next = after.chars().next();
1236 if let Some(c) = next {
1237 append_superscript(&c.to_string(), &mut out);
1238 pos += 1 + c.len_utf8();
1239 } else {
1240 out.push('^');
1241 pos += 1;
1242 }
1243 }
1244 }
1245 }
1246 '{' | '}' => {
1247 pos += ch_len;
1248 }
1249 ' ' => {
1250 if !out.ends_with(' ') {
1251 out.push(' ');
1252 }
1253 pos += ch_len;
1254 }
1255 '\n' => {
1256 if !out.ends_with(' ') {
1257 out.push(' ');
1258 }
1259 pos += ch_len;
1260 }
1261 // Punctuation that shouldn't be duplicated
1262 '~' => {
1263 // Non-breaking space
1264 out.push(' ');
1265 pos += ch_len;
1266 }
1267 _ => {
1268 out.push(ch);
1269 pos += ch_len;
1270 }
1271 }
1272 }
1273
1274 out.trim_end().to_string()
1275 }
1276
1277 /// Try to parse a `\begin{env_name}...\end{env_name}` block at the start of `input`.
1278 /// Returns (rendered_output, bytes_consumed) or None.
1279 fn try_render_env(input: &str) -> Option<(String, usize)> {
1280 let input_bytes = input.as_bytes();
1281
1282 // Check for \begin{
1283 if input.len() < 7 || &input_bytes[..7] != b"\\begin{" {
1284 return None;
1285 }
1286
1287 // Find closing }
1288 let close = input[7..].find('}')?;
1289 let env_name = &input[7..7 + close];
1290
1291 let content_start = 7 + close + 1; // after \begin{env_name}
1292 if content_start >= input.len() {
1293 return None;
1294 }
1295
1296 // Find matching \end{env_name}
1297 let end_tag = format!("\\end{{{env_name}}}");
1298 let rest = &input[content_start..];
1299
1300 // Simple depth tracking for nested braces
1301 let mut depth = 0i32;
1302 let mut search_pos = 0;
1303
1304 while search_pos < rest.len() {
1305 let remaining_search = &rest[search_pos..];
1306
1307 if remaining_search.starts_with(&end_tag) && depth == 0 {
1308 let env_content = &rest[..search_pos];
1309 let rendered = render_environment(env_name, env_content);
1310 let consumed = content_start + search_pos + end_tag.len();
1311 // --- Spacing ---
1312 return Some((rendered, consumed));
1313 }
1314
1315 match remaining_search.as_bytes().first()? {
1316 b'{' => depth += 1,
1317 b'}' => depth -= 1,
1318 _ => {}
1319 }
1320 search_pos += 1;
1321 }
1322
1323 None
1324 }
1325
1326 // --- Superscript / Subscript ---
1327
1328 fn append_superscript(s: &str, out: &mut String) {
1329 for c in s.chars() {
1330 out.push(match c {
1331 '0' => '\u{2070}',
1332 '1' => '\u{00b9}',
1333 '2' => '\u{00b2}',
1334 '3' => '\u{00b3}',
1335 '4' => '\u{2074}',
1336 '5' => '\u{2075}',
1337 '6' => '\u{2076}',
1338 '7' => '\u{2077}',
1339 '8' => '\u{2078}',
1340 '9' => '\u{2079}',
1341 '+' => '\u{207a}',
1342 '-' => '\u{207b}',
1343 '=' => '\u{207c}',
1344 '(' => '\u{207d}',
1345 ')' => '\u{207e}',
1346 'n' => '\u{207f}',
1347 'i' => '\u{2071}',
1348 'a' => '\u{1d43}',
1349 'b' => '\u{1d47}',
1350 'c' => '\u{1d9c}',
1351 'd' => '\u{1d48}',
1352 'e' => '\u{1d49}',
1353 'f' => '\u{1da0}',
1354 'g' => '\u{1d4d}',
1355 'h' => '\u{02b0}',
1356 'j' => '\u{02b2}',
1357 'k' => '\u{1d4f}',
1358 'l' => '\u{02e1}',
1359 'm' => '\u{1d50}',
1360 'o' => '\u{1d52}',
1361 'p' => '\u{1d56}',
1362 'r' => '\u{02b3}',
1363 's' => '\u{02e2}',
1364 't' => '\u{1d57}',
1365 'u' => '\u{1d58}',
1366 'v' => '\u{1d5b}',
1367 'w' => '\u{02b7}',
1368 'x' => '\u{02e3}',
1369 'y' => '\u{02b8}',
1370 'z' => '\u{1dbb}',
1371 _ => c,
1372 });
1373 }
1374 }
1375
1376 fn append_subscript(s: &str, out: &mut String) {
1377 for c in s.chars() {
1378 out.push(match c {
1379 '0' => '\u{2080}',
1380 '1' => '\u{2081}',
1381 '2' => '\u{2082}',
1382 '3' => '\u{2083}',
1383 '4' => '\u{2084}',
1384 '5' => '\u{2085}',
1385 '6' => '\u{2086}',
1386 '7' => '\u{2087}',
1387 '8' => '\u{2088}',
1388 '9' => '\u{2089}',
1389 '+' => '\u{208a}',
1390 '-' => '\u{208b}',
1391 '=' => '\u{208c}',
1392 'a' => '\u{2090}',
1393 'e' => '\u{2091}',
1394 'h' => '\u{2095}',
1395 'i' => '\u{1d62}',
1396 'k' => '\u{2096}',
1397 'l' => '\u{2097}',
1398 'm' => '\u{2098}',
1399 'n' => '\u{2099}',
1400 'o' => '\u{2092}',
1401 'p' => '\u{209a}',
1402 'r' => '\u{1d63}',
1403 's' => '\u{209b}',
1404 't' => '\u{209c}',
1405 'u' => '\u{1d64}',
1406 'v' => '\u{1d65}',
1407 'x' => '\u{2093}',
1408 _ => c,
1409 });
1410 }
1411 }
1412
1413 // --- Symbol table ---
1414
1415 type SymbolMap = HashMap<&'static str, &'static str>;
1416 fn build_symbols() -> SymbolMap {
1417 let mut m = SymbolMap::new();
1418 // Lowercase Greek
1419 for (k, v) in [
1420 ("alpha", "\u{03b1}"),
1421 ("beta", "\u{03b2}"),
1422 ("gamma", "\u{03b3}"),
1423 ("delta", "\u{03b4}"),
1424 ("epsilon", "\u{03b5}"),
1425 ("zeta", "\u{03b6}"),
1426 ("eta", "\u{03b7}"),
1427 ("theta", "\u{03b8}"),
1428 ("iota", "\u{03b9}"),
1429 ("kappa", "\u{03ba}"),
1430 ("lambda", "\u{03bb}"),
1431 ("mu", "\u{03bc}"),
1432 ("nu", "\u{03bd}"),
1433 ("xi", "\u{03be}"),
1434 ("pi", "\u{03c0}"),
1435 ("rho", "\u{03c1}"),
1436 ("sigma", "\u{03c3}"),
1437 ("tau", "\u{03c4}"),
1438 ("upsilon", "\u{03c5}"),
1439 ("phi", "\u{03c6}"),
1440 ("chi", "\u{03c7}"),
1441 ("psi", "\u{03c8}"),
1442 ("omega", "\u{03c9}"),
1443 ("varepsilon", "\u{03b5}"),
1444 ("vartheta", "\u{03d1}"),
1445 ("varphi", "\u{03c6}"),
1446 ("varrho", "\u{03f1}"),
1447 ] {
1448 m.insert(k, v);
1449 }
1450 // Uppercase Greek
1451 for (k, v) in [
1452 ("Gamma", "\u{0393}"),
1453 ("Delta", "\u{0394}"),
1454 ("Theta", "\u{0398}"),
1455 ("Lambda", "\u{039b}"),
1456 ("Xi", "\u{039e}"),
1457 ("Pi", "\u{03a0}"),
1458 ("Sigma", "\u{03a3}"),
1459 ("Upsilon", "\u{03a5}"),
1460 ("Phi", "\u{03a6}"),
1461 ("Psi", "\u{03a8}"),
1462 ("Omega", "\u{03a9}"),
1463 ] {
1464 m.insert(k, v);
1465 }
1466 // Miscellaneous
1467 for (k, v) in [
1468 ("infty", "\u{221e}"),
1469 ("partial", "\u{2202}"),
1470 ("nabla", "\u{2207}"),
1471 ("ell", "\u{2113}"),
1472 ("hbar", "\u{210f}"),
1473 ("Im", "\u{2111}"),
1474 ("Re", "\u{211c}"),
1475 ("emptyset", "\u{2205}"),
1476 ("varnothing", "\u{2205}"),
1477 ("aleph", "\u{2135}"),
1478 ("angle", "\u{2220}"),
1479 ("measuredangle", "\u{2221}"),
1480 ("langle", "\u{27e8}"),
1481 ("rangle", "\u{27e9}"),
1482 ("perp", "\u{22a5}"),
1483 ("parallel", "\u{2225}"),
1484 ("nparallel", "\u{2226}"),
1485 ("prime", "\u{2032}"),
1486 ("surd", "\u{221a}"),
1487 ("top", "\u{22a4}"),
1488 ("bot", "\u{22a5}"),
1489 ("imath", "\u{0131}"),
1490 ("jmath", "\u{0237}"),
1491 ("wp", "\u{2118}"),
1492 ("clubsuit", "\u{2663}"),
1493 ("diamondsuit", "\u{2662}"),
1494 ("heartsuit", "\u{2661}"),
1495 ("spadesuit", "\u{2660}"),
1496 ("triangle", "\u{25b3}"),
1497 ("Box", "\u{25a1}"),
1498 ("Diamond", "\u{25c7}"),
1499 ("flat", "\u{266d}"),
1500 ("natural", "\u{266e}"),
1501 ("sharp", "\u{266f}"),
1502 ("colon", ":"),
1503 ("backslash", "\\"),
1504 ] {
1505 m.insert(k, v);
1506 }
1507 // Set / relation symbols
1508 for (k, v) in [
1509 ("in", "\u{2208}"),
1510 ("notin", "\u{2209}"),
1511 ("ni", "\u{220b}"),
1512 ("subset", "\u{2282}"),
1513 ("supset", "\u{2283}"),
1514 ("subseteq", "\u{2286}"),
1515 ("supseteq", "\u{2287}"),
1516 ("subsetneq", "\u{228a}"),
1517 ("supsetneq", "\u{228b}"),
1518 ("cup", "\u{222a}"),
1519 ("bigcup", "\u{22c3}"),
1520 ("cap", "\u{2229}"),
1521 ("bigcap", "\u{22c2}"),
1522 ("vee", "\u{2228}"),
1523 ("wedge", "\u{2227}"),
1524 ("oplus", "\u{2295}"),
1525 ("ominus", "\u{2296}"),
1526 ("otimes", "\u{2297}"),
1527 ("oslash", "\u{2298}"),
1528 ("odot", "\u{2299}"),
1529 ("sqcap", "\u{2293}"),
1530 ("sqcup", "\u{2294}"),
1531 ("uplus", "\u{228e}"),
1532 ("amalg", "\u{2a3f}"),
1533 ("forall", "\u{2200}"),
1534 ("exists", "\u{2203}"),
1535 ("nexists", "\u{2204}"),
1536 ("neg", "\u{00ac}"),
1537 ("lnot", "\u{00ac}"),
1538 ("land", "\u{2227}"),
1539 ("lor", "\u{2228}"),
1540 ("implies", "\u{21d2}"),
1541 ("iff", "\u{21d4}"),
1542 ("gets", "\u{2190}"),
1543 ("sim", "\u{223c}"),
1544 ("nsim", "\u{2241}"),
1545 ("simeq", "\u{2243}"),
1546 ("nsimeq", "\u{2244}"),
1547 ("cong", "\u{2245}"),
1548 ("ncong", "\u{2247}"),
1549 ("approx", "\u{2248}"),
1550 ("napprox", "\u{2249}"),
1551 ("neq", "\u{2260}"),
1552 ("ne", "\u{2260}"),
1553 ("equiv", "\u{2261}"),
1554 ("nequiv", "\u{2262}"),
1555 ("le", "\u{2264}"),
1556 ("ge", "\u{2265}"),
1557 ("leq", "\u{2264}"),
1558 ("geq", "\u{2265}"),
1559 ("leqq", "\u{2266}"),
1560 ("geqq", "\u{2267}"),
1561 ("lneq", "\u{2268}"),
1562 ("gneq", "\u{2269}"),
1563 ("ll", "\u{226a}"),
1564 ("gg", "\u{226b}"),
1565 ("lll", "\u{22d8}"),
1566 ("ggg", "\u{22d9}"),
1567 ("prec", "\u{227a}"),
1568 ("succ", "\u{227b}"),
1569 ("preceq", "\u{227c}"),
1570 ("succeq", "\u{227d}"),
1571 ("preccurlyeq", "\u{227c}"),
1572 ("succcurlyeq", "\u{227d}"),
1573 ("propto", "\u{221d}"),
1574 ("models", "\u{22a7}"),
1575 ("dashv", "\u{22a3}"),
1576 ("vdash", "\u{22a2}"),
1577 ("mid", "|"),
1578 ("nmid", "\u{2224}"),
1579 ] {
1580 m.insert(k, v);
1581 }
1582 // Operators
1583 for (k, v) in [
1584 ("times", "\u{00d7}"),
1585 ("div", "\u{00f7}"),
1586 ("pm", "\u{00b1}"),
1587 ("mp", "\u{2213}"),
1588 ("cdot", "\u{00b7}"),
1589 ("ast", "\u{2217}"),
1590 ("circ", "\u{2218}"),
1591 ("bullet", "\u{2022}"),
1592 ("setminus", "\u{2216}"),
1593 ("smallsetminus", "\u{2216}"),
1594 ("wr", "\u{2240}"),
1595 ("dagger", "\u{2020}"),
1596 ("ddagger", "\u{2021}"),
1597 ("star", "\u{22c6}"),
1598 ("diamond", "\u{22c4}"),
1599 ] {
1600 m.insert(k, v);
1601 }
1602 // Dots
1603 for (k, v) in [
1604 ("cdots", "\u{2026}"),
1605 ("ldots", "\u{2026}"),
1606 ("vdots", "\u{22ee}"),
1607 ("ddots", "\u{22f1}"),
1608 ("idots", "\u{2026}"),
1609 ] {
1610 m.insert(k, v);
1611 }
1612 // Named functions not covered by the inline list
1613 for (k, v) in [
1614 ("arccos", "arccos"),
1615 ("arcsin", "arcsin"),
1616 ("arctan", "arctan"),
1617 ("arg", "arg"),
1618 ("cos", "cos"),
1619 ("cosh", "cosh"),
1620 ("cot", "cot"),
1621 ("coth", "coth"),
1622 ("csc", "csc"),
1623 ("deg", "deg"),
1624 ("det", "det"),
1625 ("dim", "dim"),
1626 ("exp", "exp"),
1627 ("gcd", "gcd"),
1628 ("hom", "hom"),
1629 ("inf", "inf"),
1630 ("ker", "ker"),
1631 ("lg", "lg"),
1632 ("lim", "lim"),
1633 ("liminf", "liminf"),
1634 ("limsup", "limsup"),
1635 ("ln", "ln"),
1636 ("log", "log"),
1637 ("max", "max"),
1638 ("min", "min"),
1639 ("mod", "mod"),
1640 ("sec", "sec"),
1641 ("sin", "sin"),
1642 ("sinh", "sinh"),
1643 ("sup", "sup"),
1644 ("tan", "tan"),
1645 ("tanh", "tanh"),
1646 ] {
1647 m.insert(k, v);
1648 }
1649 m
1650 }
1651
1652 static SYMBOLS: OnceLock<SymbolMap> = OnceLock::new();
1653
1654 #[cfg(test)]
1655 mod tests {
1656 use super::*;
1657
1658 #[test]
1659 fn test_superscript() {
1660 assert_eq!(render_latex_to_string("x^2"), "x\u{00b2}");
1661 }
1662 #[test]
1663 fn test_subscript() {
1664 assert_eq!(render_latex_to_string("x_1"), "x\u{2081}");
1665 }
1666 #[test]
1667 fn test_blackboard() {
1668 assert_eq!(render_latex_to_string(r"\mathbb{R}"), "\u{211d}");
1669 }
1670 #[test]
1671 fn test_infty() {
1672 assert_eq!(render_latex_to_string(r"\infty"), "\u{221e}");
1673 }
1674 #[test]
1675 fn test_inline_dollar() {
1676 let r = render_latex_in_text(r"text $x^2$ more");
1677 assert_eq!(r, "text x\u{00b2} more");
1678 assert!(matches!(r, std::borrow::Cow::Owned(_)));
1679 }
1680 #[test]
1681 fn test_display_bracket() {
1682 let r = render_latex_in_text(r"text \[x^2\] more");
1683 assert_eq!(r, "text x\u{00b2} more");
1684 }
1685 #[test]
1686 fn no_math_is_borrowed_without_copy() {
1687 let r = render_latex_in_text("plain prose with `code` but no math at all");
1688 assert!(matches!(r, std::borrow::Cow::Borrowed(_)));
1689 assert_eq!(&*r, "plain prose with `code` but no math at all");
1690 // The '$' fast path must not miss \(
1691 let p = render_latex_in_text("parens \\(x^2\\) inline");
1692 assert!(matches!(p, std::borrow::Cow::Owned(_)));
1693 assert_eq!(&*p, "parens x\u{00b2} inline");
1694 }
1695 #[test]
1696 fn preserves_currency() {
1697 assert_eq!(render_latex_in_text("cost $5 and $10"), "cost $5 and $10");
1698 }
1699 #[test]
1700 fn preserves_markdown_code() {
1701 assert_eq!(
1702 render_latex_in_text("`$x^2$` and $y^2$"),
1703 "`$x^2$` and y\u{00b2}"
1704 );
1705 assert_eq!(
1706 render_latex_in_text("```sh\necho $HOME\n```"),
1707 "```sh\necho $HOME\n```"
1708 );
1709 }
1710 #[test]
1711 fn preserves_escaped_dollars_and_unknown_commands() {
1712 assert_eq!(
1713 render_latex_in_text(r"cost \$5 and $\operatorname{foo}$"),
1714 r"cost \$5 and foo"
1715 );
1716 }
1717 #[test]
1718 fn test_text() {
1719 assert_eq!(render_latex_to_string(r"\text{hello}"), "hello");
1720 }
1721 #[test]
1722 fn test_operatorname() {
1723 assert_eq!(render_latex_to_string(r"\operatorname{sgn}"), "sgn");
1724 }
1725 #[test]
1726 fn test_left_right() {
1727 assert_eq!(
1728 render_latex_to_string(r"\left(\frac{a}{b}\right)"),
1729 "((a/b))"
1730 );
1731 }
1732 #[test]
1733 fn test_mathbf() {
1734 assert_eq!(render_latex_to_string(r"\mathbf{E}"), "E");
1735 }
1736 #[test]
1737 fn test_quad() {
1738 assert_eq!(render_latex_to_string(r"a \quad b"), "a b");
1739 }
1740 #[test]
1741 fn test_environment_aligned() {
1742 let input = r"\begin{aligned} x &= y \\ a &= b \end{aligned}";
1743 let result = render_latex_to_string(input);
1744 assert!(result.contains("x"));
1745 assert!(result.contains("y"));
1746 assert!(result.contains("a"));
1747 assert!(result.contains("b"));
1748 }
1749 #[test]
1750 fn test_environment_matrix() {
1751 let input = r"\begin{pmatrix} a & b \\ c & d \end{pmatrix}";
1752 let result = render_latex_to_string(input);
1753 assert!(result.contains("a"));
1754 assert!(result.contains("b"));
1755 assert!(result.contains("c"));
1756 assert!(result.contains("d"));
1757 }
1758 #[test]
1759 fn test_environment_cases() {
1760 let input = r"\begin{cases} x^2 & x < 0 \\ 0 & x = 0 \\ \ln x & x > 0 \end{cases}";
1761 let result = render_latex_to_string(input);
1762 assert!(result.contains("x\u{00b2}"));
1763 assert!(result.contains("ln"));
1764 }
1765 }
1766
1766 lines RUST