[PHP]轻量主题函数WordPress 集成 KaTeX 数学公式渲染

WordPress 集成 KaTeX 数学公式渲染完整方案

一、为什么选 KaTeX

写数学博客绕不开公式渲染这个问题。WordPress 原生不支持 LaTeX,必须借助第三方库。主流选择有两个:MathJax 和 KaTeX。虽然已有许多插件能够实现,但是大多数需要用短代码形式渲染,短代码方案体验略差,我们重新实现直接渲染的方式实现。

MathJax 功能全面,LaTeX 支持最广,但体积大、渲染慢。它是异步渲染,页面加载时公式会先以原始代码形式出现,过一会儿才替换成排版结果,公式一多就会有明显的闪烁感。KaTeX 是 Khan Academy 开源的方案,采用同步渲染,公式几乎在页面加载完成的同时就出现,体积也小得多。代价是 LaTeX 支持范围不如 MathJax 全,一些冷门宏包无法使用,但对于数学博客的日常写作来说完全够用。

二、两种部署方案

KaTeX 就最重要的三个东西:katex.min.css、katex.min.js 和 fonts/ 文件夹,可以选择利用CDN引入,也可以选择本地引入。

本地部署是把文件下载下来放到自己的服务器上,通过主题目录加载。优点是不依赖第三方服务,CDN 出问题公式照样显示;同域名加载速度快;版本完全可控。缺点是初次部署需要手动下载和上传,占用两三 MB 服务器空间。这是推荐方案。

CDN 加载是直接从 jsDelivr 加载 KaTeX 文件,复制代码就能用,适合快速测试。缺点是国内访问 jsDelivr 时快时慢,偶尔会超时导致公式不显示,长期使用还是本地部署更稳定。

两种方案只有第一步不同,后面的代码完全一样。

三、方案 A:本地部署

3.1 下载并上传

打开 KaTeX 的 GitHub Releases 页面(https://github.com/KaTeX/KaTeX/releases),下载最新版本,解压后会得到一个 katex 文件夹,里面包含 katex.min.css、katex.min.js、contrib/ 子文件夹和 fonts/ 子文件夹。

需要特别注意的是 fonts 文件夹里的 .woff2 字体文件一个都不能少。katex.min.css 通过相对路径 fonts/KaTeX_xxx.woff2 引用它们,缺了字体公式里的符号会显示成方框或乱码。

用 FTP 或主机控制面板的文件管理器,把整个 katex 文件夹上传到 wp-content/themes/你的主题/katex/。如果用的是子主题,就放在子主题目录下,本文后面的代码用 get_stylesheet_directory_uri() 获取路径,会自动指向当前激活的主题。

3.2 完整代码

把以下代码加到主题的 functions.php 末尾:

php 复制代码
<?php
/**
 * 自定义 KaTeX 渲染(本地资源 + 直接渲染 + 精确限定范围)
 * 支持 $$...$$(块级)和 $...$(行内)
 * 保护代码块和行内代码不被公式提取干扰
 */

// ========== 1. 加载本地 KaTeX 资源 ==========
function custom_katex_assets() {
    if ( ! is_singular() ) return;
    $katex_url = get_stylesheet_directory_uri() . '/katex/';
    wp_enqueue_style( 'katex-style', $katex_url . 'katex.min.css', array(), '0.16.22' );
    wp_enqueue_script( 'katex-core', $katex_url . 'katex.min.js', array(), '0.16.22', true );
}
add_action( 'wp_enqueue_scripts', 'custom_katex_assets', 99 );

// ========== 2. 提取公式并替换为占位 span ==========
function custom_extract_katex( $content ) {
    if ( ! is_singular() || ! in_the_loop() || ! is_main_query() ) {
        return $content;
    }

    // 存放被保护的代码片段
    $placeholders = array();
    $i = 0;

    // 保护函数:用 HTML 注释做占位符
    $protect = function( $m ) use ( &$placeholders, &$i ) {
        $key = '<!--KATEX_CODE_' . $i . '-->';
        $placeholders[ $key ] = $m[0];
        $i++;
        return $key;
    };

    // 1) 保护 Markdown 代码块 ```...```
    $content = preg_replace_callback( '/```.*?```/s', $protect, $content );

    // 2) 保护 Markdown 行内代码 `...`
    $content = preg_replace_callback( '/`[^`\n]+`/', $protect, $content );

    // 3) 保护已有的 <pre>...</pre> 标签
    $content = preg_replace_callback( '/<pre\b[^>]*>.*?<\/pre>/is', $protect, $content );

    // 4) 保护已有的 <code>...</code> 标签
    $content = preg_replace_callback( '/<code\b[^>]*>.*?<\/code>/is', $protect, $content );

    // 5) 保护已有的 <script>...</script>、<style>...</style>
    $content = preg_replace_callback( '/<script\b[^>]*>.*?<\/script>/is', $protect, $content );
    $content = preg_replace_callback( '/<style\b[^>]*>.*?<\/style>/is', $protect, $content );

    // 公式准备函数
    $prepare = function( $raw ) {
        // 还原 Markdown 产生的 <em> 斜体标签为下划线
        $raw = str_replace( array( '<em>', '</em>' ), '_', $raw );
        // 解码 HTML 实体,再编码,避免破坏 HTML 结构
        $formula = html_entity_decode( trim( $raw ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
        return htmlspecialchars( $formula, ENT_QUOTES, 'UTF-8' );
    };

    // 提取块级公式 $$...$$
    $content = preg_replace_callback(
        '/\$\$(.+?)\$\$/s',
        function ( $m ) use ( $prepare ) {
            return '<span class="katex-eq" data-display="true">' . $prepare( $m[1] ) . '</span>';
        },
        $content
    );

    // 提取行内公式 $...$
    $content = preg_replace_callback(
        '/(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)/s',
        function ( $m ) use ( $prepare ) {
            return '<span class="katex-eq" data-display="false">' . $prepare( $m[1] ) . '</span>';
        },
        $content
    );

    // 恢复被保护的代码片段
    foreach ( $placeholders as $key => $value ) {
        $content = str_replace( $key, $value, $content );
    }

    return $content;
}
add_filter( 'the_content', 'custom_extract_katex', -999 );

// ========== 3. 在文章外层包裹唯一容器 ==========
function custom_wrap_katex_content( $content ) {
    if ( is_singular() && in_the_loop() && is_main_query() ) {
        return '<div id="katex-content">' . $content . '</div>';
    }
    return $content;
}
add_filter( 'the_content', 'custom_wrap_katex_content', 1 );

// ========== 4. 页脚渲染脚本 ==========
function custom_katex_footer_script() {
    if ( ! is_singular() ) return;
    ?>
    <script>
    document.addEventListener('DOMContentLoaded', function () {
        var container = document.getElementById('katex-content');
        if (!container) return;

        var elements = container.querySelectorAll('.katex-eq');
        Array.prototype.forEach.call(elements, function (el) {
            var formula = el.textContent;
            var display = el.getAttribute('data-display') === 'true';
            var span = document.createElement('span');
            try {
                katex.render(formula, span, {
                    displayMode: display,
                    throwOnError: false
                });
            } catch (e) {
                span.style.color = 'red';
                span.textContent = e.message;
            }
            el.parentNode.replaceChild(span, el);
        });
    });
    </script>
    <?php
}
add_action( 'wp_footer', 'custom_katex_footer_script', 100 );
?>

四、方案 B:CDN 加载

如果不想下载文件,把上面第一段 custom_katex_assets 替换成:

php 复制代码
function custom_katex_assets() {
    if ( ! is_singular() ) return;
    wp_enqueue_style(
        'katex-style',
        'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css',
        array(), '0.16.22'
    );
    wp_enqueue_script(
        'katex-core',
        'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js',
        array(), '0.16.22', true
    );
}
add_action( 'wp_enqueue_scripts', 'custom_katex_assets', 99 );

其余代码不变。

五、实现原理

5.1 第一段:加载资源

这一段负责把 KaTeX 的 CSS 和 JS 加载到文章页面。is_singular() 判断当前是不是文章页或单页面,如果不是就直接返回,不在首页、归档页、搜索结果页浪费带宽。wp_enqueue_style() 和 wp_enqueue_script() 是 WordPress 标准的资源加载函数,把文件注册到资源队列里由 WordPress 统一输出。最后一个参数 true 表示把脚本放在 </body> 前而不是 <head> 里,这样 HTML 先解析、脚本后加载,不会阻塞页面渲染。

5.2 第二段:提取公式

这是整个方案的核心。优先级设成 -999,让它在 Markdown 解析器之前运行,把公式从文本里提取出来替换成 <span> 标签。这样 Markdown 再怎么处理也动不了公式内容。并且过滤掉无需渲染的部分,即代码块和引用块。

$prepare 闭包里做两件事。第一件是把 <em> 和 </em> 还原成 _,解决下划线被 Markdown 转成斜体标签的问题。第二件是做一次 HTML 实体的解码和编码,把 &lt; 还原成 <,让 KaTeX 拿到正确的 LaTeX 源码,同时保证公式内容不会破坏 HTML 结构。

处理顺序是先块级 $$...$$ 再行内 $...$,避免 $$ 被行内正则从中间切开匹配成两个错误的行内公式。

5.3 第三段:包裹容器

给文章正文包一个 <div id="katex-content">。页脚脚本只在这个容器里找公式,不会误渲染标题、侧边栏、热门文章里的 $ 符号。is_singular()、in_the_loop()、is_main_query() 三个条件确保只在文章正文上包裹,不影响其他调用 the_content 的地方。

5.4 第四段:页脚渲染

在页脚遍历所有 .katex-eq 占位 span。用 el.textContent 读取公式内容,浏览器会自动把 &lt; 解码成 <,所以 KaTeX 拿到的是正确的 LaTeX 源码。用 data-display 区分块级和行内,调用 katex.render() 渲染,然后替换掉占位 span。throwOnError: false 让公式出错时显示红色错误信息,而不是中断整个页面。

六、公式写作规范

由于这个函数规避了一些WordPress的Markdown字符过滤,所以需要注意一些不同的Katex的符号。

6.1 定界符

行内公式用 $...$,例如 $x_n$、$\sin x$。块级公式用 $$...$$,例如 $$ \left[-\frac{\pi}{2},\frac{\pi}{2}\right] $$。

不要用 \(...\) 和 \[...\]。Markdown 会把里面的反斜杠当作转义字符吃掉,KaTeX 看到的是裸括号,不会渲染。$ 不是 Markdown 的转义字符,所以安全。

6.2 花括号

凡是需要显示为花括号的地方,都用 \lbrace 和 \rbrace。具体来说,\{ 改成 \lbrace,\} 改成 \rbrace,\Big\{ 改成 \Big\lbrace,\Big\} 改成 \Big\rbrace。

原因和定界符一样。\{ 和 \} 里的反斜杠会被 Markdown 吃掉,变成裸花括号。KaTeX 看到裸花括号会报语法错误,因为在 LaTeX 里花括号是分组符号,不是可视定界符。\lbrace 和 \rbrace 里的 \l、\r 不是 Markdown 转义序列,安全。

6.3 块级公式格式

块级公式独立成行,前后空行,公式内容写在一行内不换行,不用加 \displaystyle。独立成行是因为 Markdown 会把连续的行合并成段落,混在一行会让公式的 display 属性失效。一行写完是因为多行公式容易被解析成列表或代码块,导致 $$ 被拆散。

6.4 其他字符

百分号用 \%,因为 % 在 LaTeX 里是注释符号。井号用 \#,因为 # 在 LaTeX 里是宏参数。下划线 _ 直接写,不用 \_,KaTeX 支持。

6.5 示例

错误写法:

复制代码
设 \(\{x_n\}\) 是数列,若 \(\lim_{n\to\infty} x_n = A\)
\[
\delta=\min\Big\{ f(x_0+\varepsilon)-y_0,\ y_0-f(x_0-\varepsilon) \Big\}>0.
\]

正确写法:

复制代码
设 $\lbrace x_n \rbrace$ 是数列,若 $\lim_{n\to\infty} x_n = A$

$$ \delta=\min\Big\lbrace f(x_0+\varepsilon)-y_0,\ y_0-f(x_0-\varepsilon) \Big\rbrace>0. $$

八、总结

核心思路就是,在 Markdown 解析之前把公式提取到 HTML 标签里,解析之后再用 JS 精确渲染,这个函数直接放在主题的function.php就可以实现。

博客文章地址:https://blog.huimy.top/21/613.html

相关推荐
Elastic 中国社区官方博客7 分钟前
我们如何通过 70 多个拉取请求,将 OpenTelemetry Java agent 迁移到 invokedynamic
java·大数据·开发语言·elasticsearch·搜索引擎·全文检索·es
ServBay10 分钟前
AI说代码没漏洞?马上被 PHP 打脸了
aigc·php·ai编程
tyn18819 分钟前
记录本地安装nacos服务的方法及异常
java·开发语言
步行cgn1 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
传奇开心果编程2 小时前
【Rust入门练中学】第5课:引用与借用
开发语言·rust·引用·编程语言理论·通用编程语言·借用
qetfw2 小时前
Bash 变量:export、位置参数与默认值展开验证
linux·开发语言·chrome·bash
电子云与长程纠缠2 小时前
UE5魂系游戏开发教程05
开发语言·学习·游戏·ui·ue5
tq10862 小时前
对PHP漏洞的思考
php
wangruofeng2 小时前
黑色十月:AI 找到的漏洞,两小时数十万次利用
人工智能·安全·php
cspttty2 小时前
2027风险管理岗准备路线:数学与应用数学如何补齐金融、SQL与Python
开发语言·python·金融