From 891a576dc0723d2b7f4e063ee6edc1d17be0b099 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 16 Jul 2026 14:09:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(deps):=20=E5=BC=95=E5=85=A5=20katex-rs=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=95=B0=E5=AD=A6=E5=85=AC=E5=BC=8F?= =?UTF-8?q?=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯 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 个单测通过。 --- Cargo.lock | 97 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 6 +++ src/api/katex.rs | 115 +++++++++++++++++++++++++++++++++++++++++++++++ src/api/mod.rs | 3 ++ 4 files changed, 221 insertions(+) create mode 100644 src/api/katex.rs diff --git a/Cargo.lock b/Cargo.lock index 5272352..6b2f26c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -398,6 +398,31 @@ dependencies = [ "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]] name = "brotli" version = "8.0.4" @@ -895,6 +920,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", + "strsim", "syn", ] @@ -2155,6 +2181,12 @@ dependencies = [ "http", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -2585,6 +2617,24 @@ dependencies = [ "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]] name = "keyboard-types" version = "0.7.0" @@ -3394,6 +3444,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "proc-macro-crate" version = "3.5.0" @@ -3661,6 +3721,15 @@ dependencies = [ "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]] name = "raw-cpuid" version = "11.6.0" @@ -4246,6 +4315,33 @@ dependencies = [ "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]] name = "subsecond" version = "0.7.9" @@ -5512,6 +5608,7 @@ dependencies = [ "http", "image", "js-sys", + "katex-rs", "lol_html", "md-5 0.10.6", "mimalloc", diff --git a/Cargo.toml b/Cargo.toml index 57d212d..a1a5bf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,11 @@ tokio-stream = { version = "0.1", features = ["sync"], optional = true } # 且对全静态 musl 链接友好(生产 Docker 镜像即 musl 目标)。 # server-only:经 server feature 启用;WASM 前端构建不编译(见 main.rs cfg 门控)。 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] 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:tokio-stream", "dep:mimalloc", + "dep:katex-rs", ] diff --git a/src/api/katex.rs b/src/api/katex.rs new file mode 100644 index 0000000..5df202f --- /dev/null +++ b/src/api/katex.rs @@ -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`:只产出视觉层 ``,不含 MathML +//! 语义层(`` 等)。这样 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` + /// 宏表,非 `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`] 同样在失败时回退到转义原文。调用方负责块级包裹 +/// (如 `

`),这里只产出 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 不应产出 标签(那是 HtmlAndMathml / Mathml 模式)。 + let html = render_inline("a^2 + b^2 = c^2"); + assert!( + !html.contains(" 标签, got: {html}" + ); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index aa2a067..52a04d5 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -20,6 +20,9 @@ pub mod error; pub mod health; /// 图片服务的 Axum 处理器。 pub mod image; +/// KaTeX 服务端数学公式渲染(server-only)。 +#[cfg(feature = "server")] +pub mod katex; /// Markdown 渲染与 HTML 清理。 pub mod markdown; /// 文章 CRUD 相关接口。