1#![cfg(feature = "server")]
17
18use std::borrow::Cow;
19
20use katex::macros::MacroDefinition;
21use katex::{KatexContext, OutputFormat, Settings};
22
23fn physics_macros() -> &'static [(&'static str, MacroDefinition)] {
39 &[
40 (r"\RR", MacroDefinition::StaticStr(r"\mathbb{R}")),
42 (r"\ZZ", MacroDefinition::StaticStr(r"\mathbb{Z}")),
43 (r"\NN", MacroDefinition::StaticStr(r"\mathbb{N}")),
44 (r"\QQ", MacroDefinition::StaticStr(r"\mathbb{Q}")),
45 (r"\CC", MacroDefinition::StaticStr(r"\mathbb{C}")),
46 (r"\dd", MacroDefinition::StaticStr(r"\mathrm{d}")),
50 (
51 r"\dv",
52 MacroDefinition::StaticStr(r"\frac{\mathrm{d}#1}{\mathrm{d}#2}"),
53 ),
54 (
55 r"\pdv",
56 MacroDefinition::StaticStr(r"\frac{\partial #1}{\partial #2}"),
57 ),
58 (r"\grad", MacroDefinition::StaticStr(r"\nabla")),
60 (r"\divg", MacroDefinition::StaticStr(r"\nabla \cdot")),
61 (r"\curl", MacroDefinition::StaticStr(r"\nabla \times")),
62 (r"\bra", MacroDefinition::StaticStr(r"\langle #1 |")),
64 (r"\ket", MacroDefinition::StaticStr(r"| #1 \rangle")),
65 (
66 r"\braket",
67 MacroDefinition::StaticStr(r"\langle #1 | #2 \rangle"),
68 ),
69 (
70 r"\expval",
71 MacroDefinition::StaticStr(r"\langle #1 \rangle"),
72 ),
73 (r"\abs", MacroDefinition::StaticStr(r"\left| #1 \right|")),
75 (r"\norm", MacroDefinition::StaticStr(r"\left\| #1 \right\|")),
76 (r"\vu", MacroDefinition::StaticStr(r"\hat{\vec{#1}}")),
78 (r"\qty", MacroDefinition::StaticStr(r"\left( #1 \right)")),
80 ]
81}
82
83fn mhchem_macros() -> &'static [(&'static str, MacroDefinition)] {
85 &[(
86 r"\tripledash",
87 MacroDefinition::StaticStr(
88 r"{\vphantom{-}\raisebox{2.56mu}{$\mkern2mu\tiny\text{-}\mkern1mu\text{-}\mkern1mu\text{-}\mkern2mu$}}",
89 ),
90 )]
91}
92
93fn inject_macros(settings: &mut Settings) {
95 let mut map = settings.macros.borrow_mut();
96 for (name, def) in physics_macros().iter().chain(mhchem_macros()) {
97 map.insert((*name).to_string(), def.clone());
98 }
99}
100
101fn inline_settings() -> Settings {
103 let mut s = Settings {
104 output: OutputFormat::Html,
105 display_mode: false,
106 throw_on_error: false,
107 ..Settings::default()
108 };
109 inject_macros(&mut s);
110 s
111}
112
113fn display_settings() -> Settings {
115 let mut s = Settings {
116 output: OutputFormat::Html,
117 display_mode: true,
118 throw_on_error: false,
119 ..Settings::default()
120 };
121 inject_macros(&mut s);
122 s
123}
124
125thread_local! {
126 static KATEX_CTX: KatexContext = KatexContext::default();
130
131 static INLINE_SETTINGS: Settings = inline_settings();
134 static DISPLAY_SETTINGS: Settings = display_settings();
135}
136
137fn expand_chem(tex: &str) -> String {
144 if !tex.contains(r"\ce") && !tex.contains(r"\pu") {
146 return tex.to_string();
147 }
148 let mut out = String::with_capacity(tex.len());
149 let mut rest = tex;
150 loop {
151 let ce = rest.find(r"\ce");
153 let pu = rest.find(r"\pu");
154 let next = match (ce, pu) {
155 (None, None) => None,
156 (Some(a), None) => Some((a, false)),
157 (None, Some(b)) => Some((b, true)),
158 (Some(a), Some(b)) => Some(if a <= b { (a, false) } else { (b, true) }),
159 };
160 match next {
161 None => {
162 out.push_str(rest);
164 return out;
165 }
166 Some((pos, is_pu)) => {
167 out.push_str(&rest[..pos]);
171 let bytes = rest.as_bytes();
172 let after_cmd = pos + 3;
175 if after_cmd < bytes.len() && bytes[after_cmd] == b'{' {
177 if let Some((content, close_end)) = read_braced(rest, after_cmd) {
180 let translated = if is_pu {
181 crate::api::mhchem::pu(content)
182 } else {
183 crate::api::mhchem::ce(content)
184 };
185 out.push_str(&translated);
186 rest = &rest[close_end..];
187 continue;
188 }
189 out.push_str(&rest[after_cmd..]);
191 return out;
192 } else {
193 out.push_str(&rest[pos..after_cmd]);
195 rest = &rest[after_cmd..];
196 continue;
197 }
198 }
199 }
200 }
201}
202
203fn expand_qty(tex: &str) -> Cow<'_, str> {
211 if !tex.contains(r"\qty") {
212 return Cow::Borrowed(tex);
213 }
214 let bytes = tex.as_bytes();
215 let mut edits: Vec<(usize, usize, &'static str)> = Vec::new();
217 let mut i = 0;
218 while i < bytes.len() {
219 if bytes[i] != b'\\' {
220 i += 1;
221 continue;
222 }
223 let name_start = i + 1;
224 let name_end = control_sequence_end(bytes, name_start);
225 if &bytes[name_start..name_end] == b"qty" {
226 let mut open = name_end;
227 while open < bytes.len() && bytes[open].is_ascii_whitespace() {
228 open += 1;
229 }
230 let pair = match bytes.get(open) {
231 Some(b'(') => Some((b'(', b')', r"\left(", r"\right)")),
232 Some(b'[') => Some((b'[', b']', r"\left[", r"\right]")),
233 _ => None,
234 };
235 if let Some((open_ch, close_ch, left, right)) = pair {
236 if let Some(close) = find_matching_delimiter(bytes, open, open_ch, close_ch) {
237 edits.push((i, open + 1, left));
238 edits.push((close, close + 1, right));
239 }
240 }
241 }
242 i = name_end;
244 }
245 if edits.is_empty() {
246 return Cow::Borrowed(tex);
247 }
248 edits.sort_unstable_by_key(|&(start, _, _)| start);
249 let mut out = String::with_capacity(tex.len() + edits.len() * 4);
250 let mut copied = 0;
251 for (start, end, replacement) in edits {
252 out.push_str(&tex[copied..start]);
254 out.push_str(replacement);
255 copied = end;
256 }
257 out.push_str(&tex[copied..]);
258 Cow::Owned(out)
259}
260
261fn control_sequence_end(bytes: &[u8], start: usize) -> usize {
263 match bytes.get(start) {
264 Some(b) if b.is_ascii_alphabetic() => {
265 let mut end = start;
266 while end < bytes.len() && bytes[end].is_ascii_alphabetic() {
267 end += 1;
268 }
269 end
270 }
271 Some(_) => start + 1,
273 None => start,
274 }
275}
276
277fn find_matching_delimiter(bytes: &[u8], open: usize, open_ch: u8, close_ch: u8) -> Option<usize> {
279 let mut depth = 0usize;
280 let mut braces = 0usize;
281 let mut i = open;
282 while i < bytes.len() {
283 match bytes[i] {
284 b'\\' => {
285 i = control_sequence_end(bytes, i + 1);
286 continue;
287 }
288 b'{' => braces += 1,
289 b'}' => braces = braces.checked_sub(1)?,
290 c if braces == 0 && c == open_ch => depth += 1,
291 c if braces == 0 && c == close_ch => {
292 depth -= 1;
293 if depth == 0 {
294 return Some(i);
295 }
296 }
297 _ => {}
298 }
299 i += 1;
300 }
301 None
302}
303
304fn read_braced(s: &str, open: usize) -> Option<(&str, usize)> {
307 let bytes = s.as_bytes();
308 debug_assert_eq!(bytes[open], b'{');
309 let mut depth = 0i32;
310 let mut i = open;
311 while i < bytes.len() {
312 match bytes[i] {
313 b'{' => depth += 1,
314 b'}' => {
315 depth -= 1;
316 if depth == 0 {
317 return Some((&s[open + 1..i], i + 1));
318 }
319 }
320 _ => {}
321 }
322 i += 1;
323 }
324 None
325}
326
327pub fn render_inline(tex: &str) -> String {
331 let tex = expand_chem(&expand_qty(tex));
332 KATEX_CTX.with(|ctx| {
333 INLINE_SETTINGS.with(|settings| {
334 katex::render_to_string(ctx, &tex, settings)
335 .map(|html| sort_svg_attributes(&html))
336 .unwrap_or_else(|_| crate::utils::html::escape_html(&tex))
337 })
338 })
339}
340
341pub fn render_display(tex: &str) -> String {
346 let tex = expand_chem(&expand_qty(tex));
347 KATEX_CTX.with(|ctx| {
348 DISPLAY_SETTINGS.with(|settings| {
349 katex::render_to_string(ctx, &tex, settings)
350 .map(|html| sort_svg_attributes(&html))
351 .unwrap_or_else(|_| crate::utils::html::escape_html(&tex))
352 })
353 })
354}
355
356fn sort_svg_attributes(html: &str) -> String {
364 let mut out = String::with_capacity(html.len());
365 let mut rest = html;
366 while let Some(start) = ["<svg ", "<path "]
367 .iter()
368 .filter_map(|tag| rest.find(tag))
369 .min()
370 {
371 let Some(end) = rest[start..].find('>').map(|i| start + i) else {
372 break;
373 };
374 let tag_end = rest[start + 1..].find(' ').map_or(end, |i| start + 1 + i);
375 let (self_closing, attrs_end) = if rest[..end].ends_with('/') {
376 (true, end - 1)
377 } else {
378 (false, end)
379 };
380 let mut attrs: Vec<&str> = Vec::new();
381 let mut cursor = tag_end;
382 let attr_src = &rest[..attrs_end];
383 while let Some(eq) = attr_src[cursor..].find("=\"").map(|i| cursor + i) {
384 let value_start = eq + 2;
385 let Some(value_end) = attr_src[value_start..].find('"').map(|i| value_start + i) else {
386 break;
387 };
388 attrs.push(attr_src[cursor..=value_end].trim_start());
389 cursor = value_end + 1;
390 }
391 attrs.sort_unstable();
392 out.push_str(&rest[..tag_end]);
393 for attr in attrs {
394 out.push(' ');
395 out.push_str(attr);
396 }
397 if self_closing {
398 out.push('/');
399 }
400 out.push('>');
401 rest = &rest[end + 1..];
402 }
403 out.push_str(rest);
404 out
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn render_inline_produces_katex_span() {
413 let html = render_inline("E = mc^2");
414 assert!(
415 html.contains("katex"),
416 "内联公式应产出含 katex class 的 span, got: {html}"
417 );
418 }
419
420 #[test]
421 fn render_display_produces_katex_display() {
422 let html = render_display("\\frac{a}{b}");
423 assert!(
424 html.contains("katex-display"),
425 "块级公式应产出含 katex-display class 的结构, got: {html}"
426 );
427 }
428
429 #[test]
430 fn render_inline_does_not_emit_math_tag() {
431 let html = render_inline("a^2 + b^2 = c^2");
433 assert!(
434 !html.contains("<math"),
435 "Html 输出不应含 <math> 标签, got: {html}"
436 );
437 }
438
439 #[test]
443 fn physics_macro_unit_vector_renders() {
444 let html = render_inline(r"\vu{i}");
446 assert!(
447 html.contains("katex") && !html.contains("katex-error"),
448 "\\vu 应正确渲染而非红字, got: {html}"
449 );
450 }
451
452 #[test]
453 fn physics_macro_divergence_does_not_override_division() {
454 let divg = render_inline(r"\divg \vec{F}");
456 let div = render_inline(r"a \div b");
457 assert!(
458 !divg.contains("katex-error"),
459 "\\divg 应正确渲染而非红字, got: {divg}"
460 );
461 assert!(
462 !div.contains("katex-error"),
463 "\\div 应仍是除号而非红字, got: {div}"
464 );
465 assert_ne!(divg, div, "\\divg 与 \\div 输出应不同");
467 }
468
469 #[test]
470 fn physics_macro_number_sets_renders() {
471 for m in [r"\RR", r"\ZZ", r"\NN", r"\QQ", r"\CC"] {
472 let html = render_inline(m);
473 assert!(
474 !html.contains("katex-error"),
475 "{m} 应正确渲染而非红字, got: {html}"
476 );
477 }
478 }
479
480 #[test]
481 fn physics_macro_calculus_renders() {
482 for tex in [r"\dv{f}{x}", r"\pdv{f}{x}", r"\dd{x}"] {
484 let html = render_inline(tex);
485 assert!(
486 !html.contains("katex-error"),
487 "{tex} 应正确渲染而非红字, got: {html}"
488 );
489 }
490 }
491
492 #[test]
493 fn physics_macro_bare_dd_renders() {
494 for tex in [
497 r"\frac{\dd}{\dd{t}}",
498 r"\begin{aligned} \oint_{\partial S} \vec{E} \cdot \dd{\vec{l}} &= -\frac{\dd}{\dd{t}} \int_S \vec{B} \cdot \dd{\vec{A}} \end{aligned}",
499 r"\frac{\dd}{\dd{t}} \pdv{\mathcal{L}}{\dot{q}_i} - \pdv{\mathcal{L}}{q_i} = 0",
500 r"\int f(x) \dd x",
501 ] {
502 let html = render_display(tex);
503 assert!(
504 !html.contains("#cc0000"),
505 "{tex} 应正确渲染而非红字, got: {html}"
506 );
507 }
508 let html = render_inline(r"\dd{x}");
510 assert!(html.contains(">d<") && html.contains(">x<"), "got: {html}");
511 }
512
513 #[test]
514 fn physics_macro_dirac_notation_renders() {
515 for tex in [
516 r"\bra{\psi}",
517 r"\ket{\phi}",
518 r"\braket{\psi}{\phi}",
519 r"\expval{A}",
520 ] {
521 let html = render_inline(tex);
522 assert!(
523 !html.contains("katex-error"),
524 "{tex} 应正确渲染而非红字, got: {html}"
525 );
526 }
527 }
528
529 #[test]
530 fn physics_macro_abs_norm_qty_renders() {
531 for tex in [r"\abs{x}", r"\norm{v}", r"\qty{a + b}"] {
532 let html = render_inline(tex);
533 assert!(
534 !html.contains("katex-error"),
535 "{tex} 应正确渲染而非红字, got: {html}"
536 );
537 }
538 }
539
540 #[test]
541 fn expand_qty_rewrites_delimiter_forms() {
542 for (input, expected) in [
543 (r"\qty(\dfrac{a}{b})", r"\left(\dfrac{a}{b}\right)"),
544 (r"\qty[a, b]", r"\left[a, b\right]"),
545 (r"\qty (x)", r"\left(x\right)"),
546 (r"\qty(f(x) + g(x))^2", r"\left(f(x) + g(x)\right)^2"),
548 (r"\qty(f\qty(x))", r"\left(f\left(x\right)\right)"),
549 (r"\qty(\text{)} x)", r"\left(\text{)} x\right)"),
551 (r"\qty(\{x\} \))", r"\left(\{x\} \)\right)"),
552 (r"\qty(\text{浓度})", r"\left(\text{浓度}\right)"),
553 (r"a \qty(b) c \qty[d]", r"a \left(b\right) c \left[d\right]"),
554 ] {
555 assert_eq!(expand_qty(input), expected, "input: {input}");
556 }
557 }
558
559 #[test]
560 fn expand_qty_leaves_other_forms_untouched() {
561 for input in [
562 r"\qty{a + b}",
563 r"\qtyx(a)",
564 r"a \\qty(b)",
565 r"\qty(a",
567 r"{\qty(a} )",
568 r"x^2",
569 ] {
570 assert!(
571 matches!(expand_qty(input), Cow::Borrowed(s) if s == input),
572 "{input} 应原样保留"
573 );
574 }
575 }
576
577 #[test]
578 fn physics_macro_qty_parentheses_render_as_left_right() {
579 assert_eq!(
581 render_inline(r"\qty(\dfrac{a}{b})"),
582 render_inline(r"\left(\dfrac{a}{b}\right)")
583 );
584 assert_eq!(
585 render_display(r"\qty[\frac{1}{2}]"),
586 render_display(r"\left[\frac{1}{2}\right]")
587 );
588 }
589
590 #[test]
594 fn mhchem_water_renders() {
595 let html = render_inline(r"\ce{H2O}");
596 assert!(
597 html.contains("katex") && !html.contains("katex-error"),
598 "\\ce{{H2O}} 应正确渲染而非红字, got: {html}"
599 );
600 }
601
602 #[test]
603 fn mhchem_reaction_with_arrow_renders() {
604 let html = render_display(r"\ce{2H2 + O2 -> 2H2O}");
605 assert!(
606 !html.contains("katex-error"),
607 "反应方程式应正确渲染而非红字, got: {html}"
608 );
609 }
610
611 #[test]
612 fn mhchem_gas_arrow_superscript_renders() {
613 let html = render_display(r"\ce{CaCO3 ->[\Delta] CaO + CO2 ^}");
616 assert!(
617 !html.contains("katex-error"),
618 "气体箭头公式应正确渲染而非红字, got: {html}"
619 );
620 }
621
622 #[test]
623 fn mhchem_pu_units_renders() {
624 let html = render_inline(r"\pu{9.8 m/s^2}");
629 assert!(
630 html.contains("katex") && !html.contains("katex-error"),
631 "\\pu 单位应正确渲染而非红字, got: {html}"
632 );
633 }
634
635 #[test]
636 fn expand_chem_pu_is_actually_translated() {
637 let out = expand_chem(r"\pu{9.8 m/s^2}");
639 assert_ne!(
640 out, r"\pu{9.8 m/s^2}",
641 "\\pu 应被 mhchem::pu 转译而非原样保留, got: {out}"
642 );
643 assert!(
644 !out.contains(r"\pu{"),
645 "转译后不应残留 \\pu{{ 命令, got: {out}"
646 );
647 }
648
649 #[test]
650 fn expand_chem_preserves_multibyte_utf8() {
651 let out = expand_chem(r"\text{浓度} \ce{H2O}");
654 assert!(out.contains("浓度"), "中文应原样保留, got: {out}");
655 assert!(
656 !out.contains(r"\ce{"),
657 "化学公式应被转译、不残留 \\ce{{ 命令, got: {out}"
658 );
659 assert_eq!(expand_chem(r"纯中文无公式"), r"纯中文无公式");
661 }
662
663 #[test]
667 fn text_mode_middle_dot_does_not_render_red() {
668 let html = render_inline(r"\text{m·K}");
669 assert!(
670 !html.contains("#cc0000"),
671 "\\text{{m·K}} 不应渲染为红字错误, got: {html}"
672 );
673 assert!(html.contains('⋅'), "应渲染 U+22C5 glyph, got: {html}");
675 }
676
677 #[test]
678 fn issue13_wien_law_formula_does_not_render_red() {
679 let html = render_inline(r"b \approx 2.898\times10^{-3}\,\text{m·K}");
681 assert!(
682 !html.contains("#cc0000"),
683 "issue #13 公式不应有红字, got: {html}"
684 );
685 }
686
687 #[test]
688 fn text_family_commands_render_middle_dot() {
689 for cmd in [r"\text", r"\textbf", r"\textit", r"\texttt"] {
690 let html = render_inline(&format!(r"{cmd}{{m·K}}"));
691 assert!(
692 !html.contains("#cc0000"),
693 "{cmd}{{m·K}} 不应渲染为红字错误, got: {html}"
694 );
695 }
696 }
697
698 #[test]
699 fn math_mode_middle_dot_renders_as_punctuation() {
700 let html = render_inline("a · b");
702 assert!(
703 !html.contains("#cc0000"),
704 "数学模式 · 本就能渲染, got: {html}"
705 );
706 assert!(
707 html.contains("mpunct"),
708 "数学模式 · 应保持 \\cdotp(mpunct)语义, got: {html}"
709 );
710 }
711
712 #[test]
713 fn display_mode_middle_dot_renders() {
714 let html = render_display(r"T = \frac{b}{\lambda}, \text{单位 m·K}");
715 assert!(
716 !html.contains("#cc0000"),
717 "块级公式 \\text{{m·K}} 不应渲染为红字错误, got: {html}"
718 );
719 }
720
721 #[test]
722 fn mhchem_ion_with_nested_braces_renders() {
723 let html = render_inline(r"\ce{[Cu(NH3)4]^2+}");
725 assert!(
726 !html.contains("katex-error"),
727 "络离子公式应正确渲染而非红字, got: {html}"
728 );
729 }
730
731 #[test]
732 fn mhchem_arrows_and_bonds_do_not_render_red() {
733 for input in [
736 r"\ce{CO2 + H2O <=> H2CO3}",
737 r"\ce{A <--> B}",
738 r"\ce{A <=>> B}",
739 r"\ce{A <<=> B}",
740 r"\ce{A <-> B}",
741 r"\ce{A <- B}",
742 r"\ce{A ->[H2O][\Delta] B}",
743 r"\ce{A <=>[k_1][k_2] B}",
744 r"\ce{A\bond{~}B}",
745 r"\ce{A\bond{~-}B}",
746 r"\ce{A\bond{~=}B}",
747 r"\ce{A\bond{-~-}B}",
748 r"\ce{^{227}_{90}Th}",
749 ] {
750 let html = render_display(input);
751 assert!(
752 !html.contains("#cc0000"),
753 "{input} 不应渲染为红字, got: {html}"
754 );
755 }
756 }
757
758 #[test]
759 fn svg_formulas_render_deterministically() {
760 let first = render_display(r"\sqrt{\pi} + \sqrt{\frac{a}{b}}");
762 assert!(first.contains("<svg"), "应含 svg, got: {first}");
763 for _ in 0..20 {
764 assert_eq!(first, render_display(r"\sqrt{\pi} + \sqrt{\frac{a}{b}}"));
765 }
766 }
767
768 #[test]
769 fn sort_svg_attributes_orders_names_and_keeps_rest() {
770 let html = r#"<span class="a"><svg xmlns="x" width="1em" height="2em"><path d="M0 0 L1 1"/></svg></span>"#;
771 assert_eq!(
772 sort_svg_attributes(html),
773 r#"<span class="a"><svg height="2em" width="1em" xmlns="x"><path d="M0 0 L1 1"/></svg></span>"#
774 );
775 }
776}