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_inline_does_not_emit_math_tag() {
264 let html = render_inline("a^2 + b^2 = c^2");
266 assert!(
267 !html.contains("<math"),
268 "Html 输出不应含 <math> 标签, got: {html}"
269 );
270 }
271
272 #[test]
276 fn physics_macro_unit_vector_renders() {
277 let html = render_inline(r"\vu{i}");
279 assert!(
280 html.contains("katex") && !html.contains("katex-error"),
281 "\\vu 应正确渲染而非红字, got: {html}"
282 );
283 }
284
285 #[test]
286 fn physics_macro_divergence_does_not_override_division() {
287 let divg = render_inline(r"\divg \vec{F}");
289 let div = render_inline(r"a \div b");
290 assert!(
291 !divg.contains("katex-error"),
292 "\\divg 应正确渲染而非红字, got: {divg}"
293 );
294 assert!(
295 !div.contains("katex-error"),
296 "\\div 应仍是除号而非红字, got: {div}"
297 );
298 assert_ne!(divg, div, "\\divg 与 \\div 输出应不同");
300 }
301
302 #[test]
303 fn physics_macro_number_sets_renders() {
304 for m in [r"\RR", r"\ZZ", r"\NN", r"\QQ", r"\CC"] {
305 let html = render_inline(m);
306 assert!(
307 !html.contains("katex-error"),
308 "{m} 应正确渲染而非红字, got: {html}"
309 );
310 }
311 }
312
313 #[test]
314 fn physics_macro_calculus_renders() {
315 for tex in [r"\dv{f}{x}", r"\pdv{f}{x}", r"\dd{x}"] {
317 let html = render_inline(tex);
318 assert!(
319 !html.contains("katex-error"),
320 "{tex} 应正确渲染而非红字, got: {html}"
321 );
322 }
323 }
324
325 #[test]
326 fn physics_macro_dirac_notation_renders() {
327 for tex in [
328 r"\bra{\psi}",
329 r"\ket{\phi}",
330 r"\braket{\psi}{\phi}",
331 r"\expval{A}",
332 ] {
333 let html = render_inline(tex);
334 assert!(
335 !html.contains("katex-error"),
336 "{tex} 应正确渲染而非红字, got: {html}"
337 );
338 }
339 }
340
341 #[test]
342 fn physics_macro_abs_norm_qty_renders() {
343 for tex in [r"\abs{x}", r"\norm{v}", r"\qty{a + b}"] {
344 let html = render_inline(tex);
345 assert!(
346 !html.contains("katex-error"),
347 "{tex} 应正确渲染而非红字, got: {html}"
348 );
349 }
350 }
351
352 #[test]
356 fn mhchem_water_renders() {
357 let html = render_inline(r"\ce{H2O}");
358 assert!(
359 html.contains("katex") && !html.contains("katex-error"),
360 "\\ce{{H2O}} 应正确渲染而非红字, got: {html}"
361 );
362 }
363
364 #[test]
365 fn mhchem_reaction_with_arrow_renders() {
366 let html = render_display(r"\ce{2H2 + O2 -> 2H2O}");
367 assert!(
368 !html.contains("katex-error"),
369 "反应方程式应正确渲染而非红字, got: {html}"
370 );
371 }
372
373 #[test]
374 fn mhchem_gas_arrow_superscript_renders() {
375 let html = render_display(r"\ce{CaCO3 ->[\Delta] CaO + CO2 ^}");
378 assert!(
379 !html.contains("katex-error"),
380 "气体箭头公式应正确渲染而非红字, got: {html}"
381 );
382 }
383
384 #[test]
385 fn mhchem_pu_units_renders() {
386 let html = render_inline(r"\pu{9.8 m/s^2}");
391 assert!(
392 html.contains("katex") && !html.contains("katex-error"),
393 "\\pu 单位应正确渲染而非红字, got: {html}"
394 );
395 }
396
397 #[test]
398 fn expand_chem_pu_is_actually_translated() {
399 let out = expand_chem(r"\pu{9.8 m/s^2}");
401 assert_ne!(
402 out, r"\pu{9.8 m/s^2}",
403 "\\pu 应被 mhchem::pu 转译而非原样保留, got: {out}"
404 );
405 assert!(
406 !out.contains(r"\pu{"),
407 "转译后不应残留 \\pu{{ 命令, got: {out}"
408 );
409 }
410
411 #[test]
412 fn expand_chem_preserves_multibyte_utf8() {
413 let out = expand_chem(r"\text{浓度} \ce{H2O}");
416 assert!(out.contains("浓度"), "中文应原样保留, got: {out}");
417 assert!(
418 !out.contains(r"\ce{"),
419 "化学公式应被转译、不残留 \\ce{{ 命令, got: {out}"
420 );
421 assert_eq!(expand_chem(r"纯中文无公式"), r"纯中文无公式");
423 }
424
425 #[test]
429 fn text_mode_middle_dot_does_not_render_red() {
430 let html = render_inline(r"\text{m·K}");
431 assert!(
432 !html.contains("#cc0000"),
433 "\\text{{m·K}} 不应渲染为红字错误, got: {html}"
434 );
435 assert!(html.contains('⋅'), "应渲染 U+22C5 glyph, got: {html}");
437 }
438
439 #[test]
440 fn issue13_wien_law_formula_does_not_render_red() {
441 let html = render_inline(r"b \approx 2.898\times10^{-3}\,\text{m·K}");
443 assert!(
444 !html.contains("#cc0000"),
445 "issue #13 公式不应有红字, got: {html}"
446 );
447 }
448
449 #[test]
450 fn text_family_commands_render_middle_dot() {
451 for cmd in [r"\text", r"\textbf", r"\textit", r"\texttt"] {
452 let html = render_inline(&format!(r"{cmd}{{m·K}}"));
453 assert!(
454 !html.contains("#cc0000"),
455 "{cmd}{{m·K}} 不应渲染为红字错误, got: {html}"
456 );
457 }
458 }
459
460 #[test]
461 fn math_mode_middle_dot_renders_as_punctuation() {
462 let html = render_inline("a · b");
464 assert!(
465 !html.contains("#cc0000"),
466 "数学模式 · 本就能渲染, got: {html}"
467 );
468 assert!(
469 html.contains("mpunct"),
470 "数学模式 · 应保持 \\cdotp(mpunct)语义, got: {html}"
471 );
472 }
473
474 #[test]
475 fn display_mode_middle_dot_renders() {
476 let html = render_display(r"T = \frac{b}{\lambda}, \text{单位 m·K}");
477 assert!(
478 !html.contains("#cc0000"),
479 "块级公式 \\text{{m·K}} 不应渲染为红字错误, got: {html}"
480 );
481 }
482
483 #[test]
484 fn mhchem_ion_with_nested_braces_renders() {
485 let html = render_inline(r"\ce{[Cu(NH3)4]^2+}");
487 assert!(
488 !html.contains("katex-error"),
489 "络离子公式应正确渲染而非红字, got: {html}"
490 );
491 }
492}