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:
parent
1935321da1
commit
891a576dc0
97
Cargo.lock
generated
97
Cargo.lock
generated
@ -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",
|
||||||
|
|||||||
@ -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。库名是 katex(crate 名 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
115
src/api/katex.rs
Normal 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 相关接口。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user