From 5e5583fe20d03f0608215ed2496d4c3770886038 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 23 Jul 2026 11:55:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(katex):=20=E6=B3=A8=E5=86=8C=2016=20?= =?UTF-8?q?=E4=B8=AA=E7=89=A9=E7=90=86=E5=AD=A6=E5=AE=8F=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit katex-rs 默认 Settings 无物理宏表,\vu \dv \dd \pdv \divg \curl \grad \qty \RR \ZZ \NN \QQ \CC \bra \ket \braket \expval \abs \norm 渲染为红字 (实测正确页面 648 的 137 个公式中 48 处物理宏坏掉)。 - 新增 physics_macros() 表(MacroDefinition::StaticStr,参数自动推导) - inline_settings/display_settings 构造后注入物理宏 - 刻意差异:\divg(散度)不复用 \div(除号),两者并存 - 覆写内置 \bra/\ket/\braket 为物理语义版本 - 6 个物理宏单测覆盖数集/微积分/Dirac 记号/向量/范数 --- src/api/katex.rs | 162 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 156 insertions(+), 6 deletions(-) diff --git a/src/api/katex.rs b/src/api/katex.rs index baf2590..cf5a4d0 100644 --- a/src/api/katex.rs +++ b/src/api/katex.rs @@ -15,26 +15,96 @@ #![cfg(feature = "server")] +use katex::macros::MacroDefinition; use katex::{KatexContext, OutputFormat, Settings}; -/// 内联公式(`$...$`)渲染配置工厂:`display_mode = false`。 +/// 物理学常用宏表(对齐 LaTeX `physics` 宏包 + 项目文档 8.13 节「项目物理宏表」)。 +/// +/// `katex-rs` 默认 `Settings` 无物理宏表,导致 `\vu \dv \dd \pdv \divg \curl \grad` +/// `\qty \RR \ZZ \NN \QQ \CC \bra \ket \braket \expval \abs \norm` 等渲染为红字 +/// (实测正确页面 648 的 137 个公式中 48 处物理宏坏掉)。这里把它们注册为简单 +/// 字符串宏([`MacroDefinition::StaticStr`]),crate 的 `string_to_expansion` 会自动 +/// 从 `#1`/`#2` 推导参数个数。 +/// +/// 刻意差异:`\divg`(散度)**不**覆写内置 `\div`(除号 ÷)——文档 8.13 明确两者并存。 +/// `\bra`/`\ket`/`\braket` 虽是 katex 内置宏,但内置 `\braket` 只吃 1 个参数 +/// (`\langle{#1}\rangle`),物理语义需 2 个参数(`\langle #1 | #2 \rangle`), +/// 故覆写为物理版本。 +/// +/// `\qty(...)` 的圆括号定界符匹配无法用纯字符串宏表达(TeX 无参定界符宏需 +/// `MacroExpansion.delimiters`),由 [`render_inline`]/[`render_display` 渲染前的 +/// 预处理兜底;这里注册的是花括号形式 `\qty{...}`。 +fn physics_macros() -> &'static [(&'static str, MacroDefinition)] { + &[ + // 数集 + (r"\RR", MacroDefinition::StaticStr(r"\mathbb{R}")), + (r"\ZZ", MacroDefinition::StaticStr(r"\mathbb{Z}")), + (r"\NN", MacroDefinition::StaticStr(r"\mathbb{N}")), + (r"\QQ", MacroDefinition::StaticStr(r"\mathbb{Q}")), + (r"\CC", MacroDefinition::StaticStr(r"\mathbb{C}")), + // 微积分:微分与偏导 + (r"\dd", MacroDefinition::StaticStr(r"\mathrm{d}#1")), + ( + r"\dv", + MacroDefinition::StaticStr(r"\frac{\mathrm{d}#1}{\mathrm{d}#2}"), + ), + ( + r"\pdv", + MacroDefinition::StaticStr(r"\frac{\partial #1}{\partial #2}"), + ), + // 场算子:grad/divg/curl(divg 刻意不复用 \div) + (r"\grad", MacroDefinition::StaticStr(r"\nabla")), + (r"\divg", MacroDefinition::StaticStr(r"\nabla \cdot")), + (r"\curl", MacroDefinition::StaticStr(r"\nabla \times")), + // 量子力学 Dirac 记号 + (r"\bra", MacroDefinition::StaticStr(r"\langle #1 |")), + (r"\ket", MacroDefinition::StaticStr(r"| #1 \rangle")), + ( + r"\braket", + MacroDefinition::StaticStr(r"\langle #1 | #2 \rangle"), + ), + (r"\expval", MacroDefinition::StaticStr(r"\langle #1 \rangle")), + // 向量 / 范数 / 绝对值(自动缩放定界符) + (r"\abs", MacroDefinition::StaticStr(r"\left| #1 \right|")), + (r"\norm", MacroDefinition::StaticStr(r"\left\| #1 \right\|")), + // 单位向量:带帽子 + (r"\vu", MacroDefinition::StaticStr(r"\hat{\vec{#1}}")), + // 自动缩放圆括号(花括号形式;`\qty(...)` 由预处理兜底) + (r"\qty", MacroDefinition::StaticStr(r"\left( #1 \right)")), + ] +} + +/// 把物理宏表注入到给定 `Settings` 的宏表(覆盖同名内置宏)。 +fn inject_physics_macros(settings: &mut Settings) { + let mut map = settings.macros.borrow_mut(); + for (name, def) in physics_macros() { + map.insert((*name).to_string(), def.clone()); + } +} + +/// 内联公式(`$...$`)渲染配置工厂:`display_mode = false`,含物理宏表。 fn inline_settings() -> Settings { - Settings { + let mut s = Settings { output: OutputFormat::Html, display_mode: false, throw_on_error: false, ..Settings::default() - } + }; + inject_physics_macros(&mut s); + s } -/// 块级公式(`$$...$$`)渲染配置工厂:`display_mode = true`(居中独占一行)。 +/// 块级公式(`$$...$$`)渲染配置工厂:`display_mode = true`(居中独占一行),含物理宏表。 fn display_settings() -> Settings { - Settings { + let mut s = Settings { output: OutputFormat::Html, display_mode: true, throw_on_error: false, ..Settings::default() - } + }; + inject_physics_macros(&mut s); + s + } thread_local! { @@ -114,4 +184,84 @@ mod tests { "Html 输出不应含 标签, got: {html}" ); } + + // ── 物理宏表(Fix 3a) ───────────────────────────────────────── + // katex-rs 默认无物理宏,未注册时 \vu \dd \RR 等渲染为 katex-error 红字。 + + #[test] + fn physics_macro_unit_vector_renders() { + // \vu{i} → \hat{\vec{i}}:带帽子单位向量。 + let html = render_inline(r"\vu{i}"); + assert!( + html.contains("katex") && !html.contains("katex-error"), + "\\vu 应正确渲染而非红字, got: {html}" + ); + } + + #[test] + fn physics_macro_divergence_does_not_override_division() { + // 刻意差异:\divg(散度)与 \div(除号 ÷)并存。 + let divg = render_inline(r"\divg \vec{F}"); + let div = render_inline(r"a \div b"); + assert!( + !divg.contains("katex-error"), + "\\divg 应正确渲染而非红字, got: {divg}" + ); + assert!( + !div.contains("katex-error"), + "\\div 应仍是除号而非红字, got: {div}" + ); + // 两者输出不同(\divg 展开为 \nabla \cdot,\div 是除号符号)。 + assert_ne!(divg, div, "\\divg 与 \\div 输出应不同"); + } + + #[test] + fn physics_macro_number_sets_renders() { + for m in [r"\RR", r"\ZZ", r"\NN", r"\QQ", r"\CC"] { + let html = render_inline(m); + assert!( + !html.contains("katex-error"), + "{m} 应正确渲染而非红字, got: {html}" + ); + } + } + + #[test] + fn physics_macro_calculus_renders() { + // \dv{f}{x} → d f / d x;\pdv{f}{x} → ∂ f / ∂ x;\dd{x} → dx。 + for tex in [r"\dv{f}{x}", r"\pdv{f}{x}", r"\dd{x}"] { + let html = render_inline(tex); + assert!( + !html.contains("katex-error"), + "{tex} 应正确渲染而非红字, got: {html}" + ); + } + } + + #[test] + fn physics_macro_dirac_notation_renders() { + for tex in [ + r"\bra{\psi}", + r"\ket{\phi}", + r"\braket{\psi}{\phi}", + r"\expval{A}", + ] { + let html = render_inline(tex); + assert!( + !html.contains("katex-error"), + "{tex} 应正确渲染而非红字, got: {html}" + ); + } + } + + #[test] + fn physics_macro_abs_norm_qty_renders() { + for tex in [r"\abs{x}", r"\norm{v}", r"\qty{a + b}"] { + let html = render_inline(tex); + assert!( + !html.contains("katex-error"), + "{tex} 应正确渲染而非红字, got: {html}" + ); + } + } }