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