1#![cfg(feature = "server")]
17
18use katex::macros::MacroDefinition;
19use katex::{KatexContext, OutputFormat, Settings};
20
21fn physics_macros() -> &'static [(&'static str, MacroDefinition)] {
38 &[
39 (r"\RR", MacroDefinition::StaticStr(r"\mathbb{R}")),
41 (r"\ZZ", MacroDefinition::StaticStr(r"\mathbb{Z}")),
42 (r"\NN", MacroDefinition::StaticStr(r"\mathbb{N}")),
43 (r"\QQ", MacroDefinition::StaticStr(r"\mathbb{Q}")),
44 (r"\CC", MacroDefinition::StaticStr(r"\mathbb{C}")),
45 (r"\dd", MacroDefinition::StaticStr(r"\mathrm{d}#1")),
47 (
48 r"\dv",
49 MacroDefinition::StaticStr(r"\frac{\mathrm{d}#1}{\mathrm{d}#2}"),
50 ),
51 (
52 r"\pdv",
53 MacroDefinition::StaticStr(r"\frac{\partial #1}{\partial #2}"),
54 ),
55 (r"\grad", MacroDefinition::StaticStr(r"\nabla")),
57 (r"\divg", MacroDefinition::StaticStr(r"\nabla \cdot")),
58 (r"\curl", MacroDefinition::StaticStr(r"\nabla \times")),
59 (r"\bra", MacroDefinition::StaticStr(r"\langle #1 |")),
61 (r"\ket", MacroDefinition::StaticStr(r"| #1 \rangle")),
62 (
63 r"\braket",
64 MacroDefinition::StaticStr(r"\langle #1 | #2 \rangle"),
65 ),
66 (
67 r"\expval",
68 MacroDefinition::StaticStr(r"\langle #1 \rangle"),
69 ),
70 (r"\abs", MacroDefinition::StaticStr(r"\left| #1 \right|")),
72 (r"\norm", MacroDefinition::StaticStr(r"\left\| #1 \right\|")),
73 (r"\vu", MacroDefinition::StaticStr(r"\hat{\vec{#1}}")),
75 (r"\qty", MacroDefinition::StaticStr(r"\left( #1 \right)")),
77 ]
78}
79
80fn inject_physics_macros(settings: &mut Settings) {
82 let mut map = settings.macros.borrow_mut();
83 for (name, def) in physics_macros() {
84 map.insert((*name).to_string(), def.clone());
85 }
86}
87
88fn inline_settings() -> Settings {
90 let mut s = Settings {
91 output: OutputFormat::Html,
92 display_mode: false,
93 throw_on_error: false,
94 ..Settings::default()
95 };
96 inject_physics_macros(&mut s);
97 s
98}
99
100fn display_settings() -> Settings {
102 let mut s = Settings {
103 output: OutputFormat::Html,
104 display_mode: true,
105 throw_on_error: false,
106 ..Settings::default()
107 };
108 inject_physics_macros(&mut s);
109 s
110}
111
112thread_local! {
113 static KATEX_CTX: KatexContext = KatexContext::default();
117
118 static INLINE_SETTINGS: Settings = inline_settings();
121 static DISPLAY_SETTINGS: Settings = display_settings();
122}
123
124fn expand_chem(tex: &str) -> String {
131 if !tex.contains(r"\ce") && !tex.contains(r"\pu") {
133 return tex.to_string();
134 }
135 let mut out = String::with_capacity(tex.len());
136 let mut rest = tex;
137 loop {
138 let ce = rest.find(r"\ce");
140 let pu = rest.find(r"\pu");
141 let next = match (ce, pu) {
142 (None, None) => None,
143 (Some(a), None) => Some((a, false)),
144 (None, Some(b)) => Some((b, true)),
145 (Some(a), Some(b)) => Some(if a <= b { (a, false) } else { (b, true) }),
146 };
147 match next {
148 None => {
149 out.push_str(rest);
151 return out;
152 }
153 Some((pos, is_pu)) => {
154 out.push_str(&rest[..pos]);
158 let bytes = rest.as_bytes();
159 let after_cmd = pos + 3;
162 if after_cmd < bytes.len() && bytes[after_cmd] == b'{' {
164 if let Some((content, close_end)) = read_braced(rest, after_cmd) {
167 let translated = if is_pu {
168 crate::api::mhchem::pu(content)
169 } else {
170 crate::api::mhchem::ce(content)
171 };
172 out.push_str(&translated);
173 rest = &rest[close_end..];
174 continue;
175 }
176 out.push_str(&rest[after_cmd..]);
178 return out;
179 } else {
180 out.push_str(&rest[pos..after_cmd]);
182 rest = &rest[after_cmd..];
183 continue;
184 }
185 }
186 }
187 }
188}
189
190fn read_braced(s: &str, open: usize) -> Option<(&str, usize)> {
193 let bytes = s.as_bytes();
194 debug_assert_eq!(bytes[open], b'{');
195 let mut depth = 0i32;
196 let mut i = open;
197 while i < bytes.len() {
198 match bytes[i] {
199 b'{' => depth += 1,
200 b'}' => {
201 depth -= 1;
202 if depth == 0 {
203 return Some((&s[open + 1..i], i + 1));
204 }
205 }
206 _ => {}
207 }
208 i += 1;
209 }
210 None
211}
212
213pub fn render_inline(tex: &str) -> String {
217 let tex = expand_chem(tex);
218 KATEX_CTX.with(|ctx| {
219 INLINE_SETTINGS.with(|settings| {
220 katex::render_to_string(ctx, &tex, settings)
221 .unwrap_or_else(|_| crate::utils::html::escape_html(&tex))
222 })
223 })
224}
225
226pub fn render_display(tex: &str) -> String {
231 let tex = expand_chem(tex);
232 KATEX_CTX.with(|ctx| {
233 DISPLAY_SETTINGS.with(|settings| {
234 katex::render_to_string(ctx, &tex, settings)
235 .unwrap_or_else(|_| crate::utils::html::escape_html(&tex))
236 })
237 })
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn render_inline_produces_katex_span() {
246 let html = render_inline("E = mc^2");
247 assert!(
248 html.contains("katex"),
249 "内联公式应产出含 katex class 的 span, got: {html}"
250 );
251 }
252
253 #[test]
254 fn render_display_produces_katex_display() {
255 let html = render_display("\\frac{a}{b}");
256 assert!(
257 html.contains("katex-display"),
258 "块级公式应产出含 katex-display class 的结构, got: {html}"
259 );
260 }
261
262 #[test]
263 fn render_bad_tex_does_not_panic() {
264 let html = render_inline("\\thisisnotarealmacroxyz{");
268 assert!(!html.is_empty(), "坏公式应返回非空 HTML, got empty");
269 }
270
271 #[test]
272 fn render_inline_does_not_emit_math_tag() {
273 let html = render_inline("a^2 + b^2 = c^2");
275 assert!(
276 !html.contains("<math"),
277 "Html 输出不应含 <math> 标签, got: {html}"
278 );
279 }
280
281 #[test]
285 fn physics_macro_unit_vector_renders() {
286 let html = render_inline(r"\vu{i}");
288 assert!(
289 html.contains("katex") && !html.contains("katex-error"),
290 "\\vu 应正确渲染而非红字, got: {html}"
291 );
292 }
293
294 #[test]
295 fn physics_macro_divergence_does_not_override_division() {
296 let divg = render_inline(r"\divg \vec{F}");
298 let div = render_inline(r"a \div b");
299 assert!(
300 !divg.contains("katex-error"),
301 "\\divg 应正确渲染而非红字, got: {divg}"
302 );
303 assert!(
304 !div.contains("katex-error"),
305 "\\div 应仍是除号而非红字, got: {div}"
306 );
307 assert_ne!(divg, div, "\\divg 与 \\div 输出应不同");
309 }
310
311 #[test]
312 fn physics_macro_number_sets_renders() {
313 for m in [r"\RR", r"\ZZ", r"\NN", r"\QQ", r"\CC"] {
314 let html = render_inline(m);
315 assert!(
316 !html.contains("katex-error"),
317 "{m} 应正确渲染而非红字, got: {html}"
318 );
319 }
320 }
321
322 #[test]
323 fn physics_macro_calculus_renders() {
324 for tex in [r"\dv{f}{x}", r"\pdv{f}{x}", r"\dd{x}"] {
326 let html = render_inline(tex);
327 assert!(
328 !html.contains("katex-error"),
329 "{tex} 应正确渲染而非红字, got: {html}"
330 );
331 }
332 }
333
334 #[test]
335 fn physics_macro_dirac_notation_renders() {
336 for tex in [
337 r"\bra{\psi}",
338 r"\ket{\phi}",
339 r"\braket{\psi}{\phi}",
340 r"\expval{A}",
341 ] {
342 let html = render_inline(tex);
343 assert!(
344 !html.contains("katex-error"),
345 "{tex} 应正确渲染而非红字, got: {html}"
346 );
347 }
348 }
349
350 #[test]
351 fn physics_macro_abs_norm_qty_renders() {
352 for tex in [r"\abs{x}", r"\norm{v}", r"\qty{a + b}"] {
353 let html = render_inline(tex);
354 assert!(
355 !html.contains("katex-error"),
356 "{tex} 应正确渲染而非红字, got: {html}"
357 );
358 }
359 }
360
361 #[test]
365 fn mhchem_water_renders() {
366 let html = render_inline(r"\ce{H2O}");
367 assert!(
368 html.contains("katex") && !html.contains("katex-error"),
369 "\\ce{{H2O}} 应正确渲染而非红字, got: {html}"
370 );
371 }
372
373 #[test]
374 fn mhchem_reaction_with_arrow_renders() {
375 let html = render_display(r"\ce{2H2 + O2 -> 2H2O}");
376 assert!(
377 !html.contains("katex-error"),
378 "反应方程式应正确渲染而非红字, got: {html}"
379 );
380 }
381
382 #[test]
383 fn mhchem_gas_arrow_superscript_renders() {
384 let html = render_display(r"\ce{CaCO3 ->[\Delta] CaO + CO2 ^}");
387 assert!(
388 !html.contains("katex-error"),
389 "气体箭头公式应正确渲染而非红字, got: {html}"
390 );
391 }
392
393 #[test]
394 fn mhchem_pu_units_renders() {
395 let html = render_inline(r"\pu{9.8 m/s^2}");
400 assert!(
401 html.contains("katex") && !html.contains("katex-error"),
402 "\\pu 单位应正确渲染而非红字, got: {html}"
403 );
404 }
405
406 #[test]
407 fn expand_chem_pu_is_actually_translated() {
408 let out = expand_chem(r"\pu{9.8 m/s^2}");
410 assert_ne!(
411 out, r"\pu{9.8 m/s^2}",
412 "\\pu 应被 mhchem::pu 转译而非原样保留, got: {out}"
413 );
414 assert!(
415 !out.contains(r"\pu{"),
416 "转译后不应残留 \\pu{{ 命令, got: {out}"
417 );
418 }
419
420 #[test]
421 fn expand_chem_preserves_multibyte_utf8() {
422 let out = expand_chem(r"\text{浓度} \ce{H2O}");
425 assert!(out.contains("浓度"), "中文应原样保留, got: {out}");
426 assert!(
427 !out.contains(r"\ce{"),
428 "化学公式应被转译、不残留 \\ce{{ 命令, got: {out}"
429 );
430 assert_eq!(expand_chem(r"纯中文无公式"), r"纯中文无公式");
432 }
433
434 #[test]
435 fn mhchem_ion_with_nested_braces_renders() {
436 let html = render_inline(r"\ce{[Cu(NH3)4]^2+}");
438 assert!(
439 !html.contains("katex-error"),
440 "络离子公式应正确渲染而非红字, got: {html}"
441 );
442 }
443}