feat(deps): 引入 katex-rs 服务端数学公式渲染

纯 Rust 的 katex-rs 0.2.4 重实现 KaTeX,把 TeX 公式渲染成 HTML span,
供后续 pulldown-cmark 的 InlineMath/DisplayMath 事件调用。

- Cargo.toml: katex-rs 作为 optional dep 经 server feature 启用
- src/api/katex.rs: thread_local 缓存 KatexContext/Settings(因含 RefCell
  宏表非 Sync,不能放全局 static);OutputFormat::Html 避开 MathML 标签
  使 sanitizer 无需开白名单;throw_on_error=false 防坏公式中断全文
- src/api/mod.rs: #[cfg(feature="server")] 注册 katex 模块

双 target 编译通过(server + wasm32),4 个单测通过。
This commit is contained in:
xfy 2026-07-16 14:09:14 +08:00
parent 1935321da1
commit 891a576dc0
4 changed files with 221 additions and 0 deletions

97
Cargo.lock generated
View File

@ -398,6 +398,31 @@ dependencies = [
"serde_repr", "serde_repr",
] ]
[[package]]
name = "bon"
version = "3.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561"
dependencies = [
"bon-macros",
"rustversion",
]
[[package]]
name = "bon-macros"
version = "3.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f"
dependencies = [
"darling",
"ident_case",
"prettyplease",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.4" version = "8.0.4"
@ -895,6 +920,7 @@ dependencies = [
"ident_case", "ident_case",
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim",
"syn", "syn",
] ]
@ -2155,6 +2181,12 @@ dependencies = [
"http", "http",
] ]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.5.2" version = "0.5.2"
@ -2585,6 +2617,24 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "katex-rs"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5382fea1e8edf972c23050cdfec2d12beca242e6d15e32310e5d1543a51d103"
dependencies = [
"bon",
"phf",
"phf_codegen",
"rapidhash",
"serde",
"serde_json",
"strum",
"strum_macros",
"thiserror 2.0.18",
"unicode-normalization",
]
[[package]] [[package]]
name = "keyboard-types" name = "keyboard-types"
version = "0.7.0" version = "0.7.0"
@ -3394,6 +3444,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.5.0" version = "3.5.0"
@ -3661,6 +3721,15 @@ dependencies = [
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
[[package]]
name = "rapidhash"
version = "4.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "raw-cpuid" name = "raw-cpuid"
version = "11.6.0" version = "11.6.0"
@ -4246,6 +4315,33 @@ dependencies = [
"unicode-properties", "unicode-properties",
] ]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "subsecond" name = "subsecond"
version = "0.7.9" version = "0.7.9"
@ -5512,6 +5608,7 @@ dependencies = [
"http", "http",
"image", "image",
"js-sys", "js-sys",
"katex-rs",
"lol_html", "lol_html",
"md-5 0.10.6", "md-5 0.10.6",
"mimalloc", "mimalloc",

View File

@ -58,6 +58,11 @@ tokio-stream = { version = "0.1", features = ["sync"], optional = true }
# 且对全静态 musl 链接友好(生产 Docker 镜像即 musl 目标)。 # 且对全静态 musl 链接友好(生产 Docker 镜像即 musl 目标)。
# server-only经 server feature 启用WASM 前端构建不编译(见 main.rs cfg 门控)。 # server-only经 server feature 启用WASM 前端构建不编译(见 main.rs cfg 门控)。
mimalloc = { version = "0.1", optional = true } mimalloc = { version = "0.1", optional = true }
# KaTeX 服务端数学公式渲染:纯 Rust 重实现(无 JS 运行时),渲染 $...$ / $$...$$
# 为 HTML span。库名是 katexcrate 名 katex-rs代码用 `use katex::...`。
# 默认 features 为空即原生服务端编译wasm feature 仅前端构建才需,这里不开。
# 配套前端需引入 katex.min.css + 字体(见 public/katex/、Makefile katex-css
katex-rs = { version = "0.2", optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "File", "FileList", "FormData", "Request", "RequestInit", "Response", "Headers", "ClipboardEvent", "DataTransfer", "HtmlInputElement", "EventSource", "MessageEvent"] } web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "File", "FileList", "FormData", "Request", "RequestInit", "Response", "Headers", "ClipboardEvent", "DataTransfer", "HtmlInputElement", "EventSource", "MessageEvent"] }
@ -119,4 +124,5 @@ server = [
"dep:bollard", "dep:bollard",
"dep:tokio-stream", "dep:tokio-stream",
"dep:mimalloc", "dep:mimalloc",
"dep:katex-rs",
] ]

115
src/api/katex.rs Normal file
View File

@ -0,0 +1,115 @@
//! KaTeX 服务端数学公式渲染。
//!
//! 用纯 Rust 的 [`katex`](https://crates.io/crates/katex-rs) crate 把 TeX 公式
//! 渲染成 HTML span供 pulldown-cmark 的 `InlineMath` / `DisplayMath` 事件调用。
//! 仅在 `feature = "server"` 时编译——前端 WASM 不参与公式渲染SSR 即终态)。
//!
//! 渲染策略:
//! - `OutputFormat::Html`:只产出视觉层 `<span class="katex">…</span>`,不含 MathML
//! 语义层(`<math>` 等)。这样 sanitizer 无需为 MathML 标签开白名单XSS 面最小。
//! 屏幕阅读器等无障碍场景的语义损失可接受(本站数学公式占比低)。
//! - `throw_on_error = false`:坏公式渲染成红色错误 span 而非中断整篇文章。
//!
//! 配套资源:前端必须加载 `public/katex/katex.min.css` + `fonts/`(见 Makefile
//! `katex-css`),否则只有裸 span、无数学字体排版。crate 本身不打包 CSS。
#![cfg(feature = "server")]
use katex::{KatexContext, OutputFormat, Settings};
/// 内联公式(`$...$`)渲染配置工厂:`display_mode = false`。
fn inline_settings() -> Settings {
let mut s = Settings::default();
s.output = OutputFormat::Html;
s.display_mode = false;
s.throw_on_error = false;
s
}
/// 块级公式(`$$...$$`)渲染配置工厂:`display_mode = true`(居中独占一行)。
fn display_settings() -> Settings {
let mut s = Settings::default();
s.output = OutputFormat::Html;
s.display_mode = true;
s.throw_on_error = false;
s
}
thread_local! {
/// KaTeX 上下文:含全部内置符号 / 宏表应在多次渲染间复用README 建议)。
/// 用 thread_local 而非全局 static`KatexContext` 内含 `RefCell<HashMap>`
/// 宏表,非 `Sync`,不能放 `LazyLock`。tokio 多线程 runtime 下每线程各持一份。
static KATEX_CTX: KatexContext = KatexContext::default();
/// 每线程缓存的渲染配置,避免每次渲染都重建宏表 HashMap。
/// `Settings` 同样因 `RefCell` 宏表非 `Sync`。
static INLINE_SETTINGS: Settings = inline_settings();
static DISPLAY_SETTINGS: Settings = display_settings();
}
/// 渲染内联公式 `$...$`(定界符由 pulldown-cmark 剥除)→ HTML 字符串。
///
/// 渲染失败(坏 TeX时回退到 HTML 转义后的原文,保证文章不因一个坏公式全篇崩。
pub fn render_inline(tex: &str) -> String {
KATEX_CTX.with(|ctx| {
INLINE_SETTINGS.with(|settings| {
katex::render_to_string(ctx, tex, settings)
.unwrap_or_else(|_| crate::utils::html::escape_html(tex))
})
})
}
/// 渲染块级公式 `$$...$$`(定界符由 pulldown-cmark 剥除)→ HTML 字符串。
///
/// 与 [`render_inline`] 同样在失败时回退到转义原文。调用方负责块级包裹
/// (如 `<p class="math-display">`),这里只产出 KaTeX 的 span 串。
pub fn render_display(tex: &str) -> String {
KATEX_CTX.with(|ctx| {
DISPLAY_SETTINGS.with(|settings| {
katex::render_to_string(ctx, tex, settings)
.unwrap_or_else(|_| crate::utils::html::escape_html(tex))
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_inline_produces_katex_span() {
let html = render_inline("E = mc^2");
assert!(
html.contains("katex"),
"内联公式应产出含 katex class 的 span, got: {html}"
);
}
#[test]
fn render_display_produces_katex_display() {
let html = render_display("\\frac{a}{b}");
assert!(
html.contains("katex-display"),
"块级公式应产出含 katex-display class 的结构, got: {html}"
);
}
#[test]
fn render_bad_tex_does_not_panic() {
// throw_on_error=false坏 TeX 不应 panic、不应返回 Err。
// KaTeX 可能渲染成红色错误 span也可能把未知宏当字面文本处理。
// 关键契约:返回非空字符串、不中断调用方。
let html = render_inline("\\thisisnotarealmacroxyz{");
assert!(!html.is_empty(), "坏公式应返回非空 HTML, got empty");
}
#[test]
fn render_inline_does_not_emit_math_tag() {
// OutputFormat::Html 不应产出 <math> 标签(那是 HtmlAndMathml / Mathml 模式)。
let html = render_inline("a^2 + b^2 = c^2");
assert!(
!html.contains("<math"),
"Html 输出不应含 <math> 标签, got: {html}"
);
}
}

View File

@ -20,6 +20,9 @@ pub mod error;
pub mod health; pub mod health;
/// 图片服务的 Axum 处理器。 /// 图片服务的 Axum 处理器。
pub mod image; pub mod image;
/// KaTeX 服务端数学公式渲染server-only
#[cfg(feature = "server")]
pub mod katex;
/// Markdown 渲染与 HTML 清理。 /// Markdown 渲染与 HTML 清理。
pub mod markdown; pub mod markdown;
/// 文章 CRUD 相关接口。 /// 文章 CRUD 相关接口。